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,624 @@
#!/usr/bin/env node
/**
* AI Studio 项目预处理器(最小化处理模式)
*
* 只做 100% 有把握的操作:
* 1. 完整复制项目
* 2. 分析项目结构
* 3. 生成任务文档
*
* 不做任何代码修改,全部留给 AI 处理
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const TARGET_TYPE_TO_SRC_DIR = {
prototypes: 'src/prototypes',
components: 'src/components',
themes: 'src/themes',
};
const THEME_SPLIT_SKILL_DOCS = [
'/skills/axure-prototype-workflow/theme-generation.md',
'/skills/axure-prototype-workflow/doc-generation.md',
'/skills/axure-prototype-workflow/data-generation.md',
'/skills/web-page-workflow/theme-generation.md',
'/skills/web-page-workflow/doc-generation.md',
'/skills/web-page-workflow/data-generation.md',
];
const CONFIG = {
projectRoot: path.resolve(__dirname, '..'),
tempDir: path.resolve(__dirname, '../temp'),
};
function log(message, type = 'info') {
const prefix = { info: '✓', warn: '⚠', error: '✗', progress: '⏳' }[type] || '';
console.log(`${prefix} ${message}`);
}
function ensureDir(dirPath) {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
}
function sanitizeName(rawName) {
return String(rawName || '')
.replace(/[^a-z0-9-]/gi, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
.toLowerCase();
}
function getTargetInfo(targetType, outputName) {
const srcDir = TARGET_TYPE_TO_SRC_DIR[targetType];
const outputBaseDir = path.resolve(CONFIG.projectRoot, srcDir);
const outputDir = path.join(outputBaseDir, outputName);
const relativeOutputDir = `${srcDir}/${outputName}`;
if (targetType === 'themes') {
return {
targetType,
srcDir,
outputBaseDir,
outputDir,
relativeOutputDir,
tasksFileName: '.ai-studio-theme-tasks.md',
analysisFileName: '.ai-studio-theme-analysis.json',
checkPath: `/themes/${outputName}`,
label: '主题',
};
}
return {
targetType,
srcDir,
outputBaseDir,
outputDir,
relativeOutputDir,
tasksFileName: '.ai-studio-tasks.md',
analysisFileName: '.ai-studio-analysis.json',
checkPath: `/${targetType}/${outputName}`,
label: targetType === 'components' ? '组件' : '页面',
};
}
// 递归查找所有 .tsx/.ts 文件
function findFiles(dir, extensions = ['.tsx', '.ts']) {
const results = [];
if (!fs.existsSync(dir)) return results;
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
// 跳过 node_modules
if (entry.name === 'node_modules') continue;
results.push(...findFiles(fullPath, extensions));
} else {
const ext = path.extname(entry.name);
if (extensions.includes(ext)) {
results.push(fullPath);
}
}
}
return results;
}
function copyDirectory(src, dest) {
if (!fs.existsSync(src)) return 0;
ensureDir(dest);
const entries = fs.readdirSync(src, { withFileTypes: true });
let count = 0;
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
if (entry.name === 'node_modules') continue;
count += copyDirectory(srcPath, destPath);
} else {
fs.copyFileSync(srcPath, destPath);
count++;
}
}
return count;
}
console.log('AI Studio Converter - Minimal Processing Mode\n');
function analyzeProject(targetDir) {
const analysis = {
files: [],
components: [],
dependencies: {},
structure: {},
indexHtml: null,
viteConfig: null
};
const files = findFiles(targetDir, ['.tsx', '.ts']);
files.forEach(file => {
const relativePath = path.relative(targetDir, file);
const content = fs.readFileSync(file, 'utf8');
const fileName = path.basename(file);
const fileInfo = {
path: relativePath,
isAppTsx: fileName === 'App.tsx',
isIndexTsx: fileName === 'index.tsx',
imports: []
};
const importMatches = content.matchAll(/import\s+.*from\s+['"]([^'"]+)['"]/g);
for (const match of importMatches) {
fileInfo.imports.push(match[1]);
}
analysis.files.push(fileInfo);
if (relativePath.startsWith('components/')) {
analysis.components.push(relativePath);
}
});
const indexHtmlPath = path.join(targetDir, 'index.html');
if (fs.existsSync(indexHtmlPath)) {
const htmlContent = fs.readFileSync(indexHtmlPath, 'utf8');
const importMapMatch = htmlContent.match(/<script type="importmap">([\s\S]*?)<\/script>/);
const importMap = importMapMatch ? JSON.parse(importMapMatch[1]) : null;
const styleMatches = htmlContent.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/g);
const customStyles = Array.from(styleMatches).map(m => m[1].trim());
const fontMatches = htmlContent.matchAll(/<link[^>]*href=["']([^"']*fonts\.googleapis\.com[^"']*)["'][^>]*>/g);
const fonts = Array.from(fontMatches).map(m => m[1]);
analysis.indexHtml = {
importMap,
customStyles,
fonts,
hasTailwindCDN: htmlContent.includes('cdn.tailwindcss.com')
};
}
const viteConfigPath = path.join(targetDir, 'vite.config.ts');
if (fs.existsSync(viteConfigPath)) {
const viteContent = fs.readFileSync(viteConfigPath, 'utf8');
const aliasMatch = viteContent.match(/alias:\s*{([^}]*)}/);
const aliases = aliasMatch ? aliasMatch[1].trim() : null;
const defineMatch = viteContent.match(/define:\s*{([^}]*)}/);
const envVars = defineMatch ? defineMatch[1].trim() : null;
analysis.viteConfig = {
hasAlias: !!aliases,
aliases,
hasEnvVars: !!envVars,
envVars
};
}
const packageJsonPath = path.join(targetDir, 'package.json');
if (fs.existsSync(packageJsonPath)) {
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
const deps = packageJson.dependencies || {};
analysis.dependencies = {
all: deps,
toInstall: Object.keys(deps).filter(dep => {
if (dep === 'react' || dep === 'react-dom') return false;
return true;
}),
excluded: ['react', 'react-dom']
};
} else if (analysis.indexHtml?.importMap) {
const imports = analysis.indexHtml.importMap.imports || {};
const cdnDeps = Object.keys(imports).filter(dep => {
if (dep === 'react' || dep === 'react-dom') return false;
return true;
});
analysis.dependencies = {
fromCDN: cdnDeps,
toInstall: cdnDeps,
excluded: ['react', 'react-dom']
};
}
analysis.structure = {
hasAppTsx: fs.existsSync(path.join(targetDir, 'App.tsx')),
hasIndexTsx: fs.existsSync(path.join(targetDir, 'index.tsx')),
hasIndexHtml: fs.existsSync(path.join(targetDir, 'index.html')),
hasComponentsDir: fs.existsSync(path.join(targetDir, 'components')),
hasAssetsDir: fs.existsSync(path.join(targetDir, 'assets')),
hasConstantsTs: fs.existsSync(path.join(targetDir, 'constants.ts')),
hasTypesTs: fs.existsSync(path.join(targetDir, 'types.ts')),
hasViteConfig: fs.existsSync(path.join(targetDir, 'vite.config.ts')),
hasMetadataJson: fs.existsSync(path.join(targetDir, 'metadata.json'))
};
return analysis;
}
function buildAnalysisReport(analysis) {
return {
summary: {
totalFiles: analysis.files.length,
componentCount: analysis.components.length,
dependenciesToInstall: analysis.dependencies.toInstall?.length || 0,
hasImportMap: !!analysis.indexHtml?.importMap,
hasCustomStyles: (analysis.indexHtml?.customStyles?.length || 0) > 0,
hasFonts: (analysis.indexHtml?.fonts?.length || 0) > 0
},
structure: analysis.structure,
components: analysis.components,
dependencies: analysis.dependencies,
indexHtml: analysis.indexHtml,
viteConfig: analysis.viteConfig,
files: analysis.files
};
}
function generateDefaultTasksDocument(report, targetInfo, outputName, tempDir) {
const reportPath = path.join(targetInfo.outputDir, targetInfo.analysisFileName);
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
let markdown = `# AI Studio 项目转换任务清单\n\n`;
markdown += `> **重要**: 请先阅读 \`/skills/ai-studio-project-converter/SKILL.md\` 了解转换规范\n\n`;
markdown += `**名称**: ${outputName}\n`;
markdown += `**项目位置**: \`${targetInfo.relativeOutputDir}/\`\n`;
markdown += `**原始文件**: \`${tempDir}\` (仅供参考,不要修改)\n`;
markdown += `**生成时间**: ${new Date().toLocaleString()}\n\n`;
markdown += `## 📊 项目概况\n\n`;
markdown += `- 总文件数: ${report.summary.totalFiles}\n`;
markdown += `- 组件数: ${report.summary.componentCount}\n`;
markdown += `- Import Map: ${report.summary.hasImportMap ? '✓ 存在' : '✗ 不存在'}\n`;
markdown += `- 自定义样式: ${report.summary.hasCustomStyles ? '✓ 存在' : '✗ 不存在'}\n`;
markdown += `- 外部字体: ${report.summary.hasFonts ? '✓ 存在' : '✗ 不存在'}\n`;
markdown += `- 需要安装的依赖: ${report.summary.dependenciesToInstall}\n\n`;
markdown += `## ✅ 转换任务(共 5 个)\n\n`;
markdown += `### 任务 1: 转换主应用组件\n\n`;
markdown += `**目标**: 将 \`App.tsx\` 转换为本项目组件规范\n\n`;
if (report.structure.hasAppTsx) {
markdown += `**参考文件**: \`${targetInfo.relativeOutputDir}/App.tsx\`\n\n`;
markdown += `**操作**:\n`;
markdown += `1. 重命名 \`App.tsx\`\`index.tsx\`\n`;
markdown += `2. 按照 \`/skills/ai-studio-project-converter/SKILL.md\` 中的本项目组件规范改造\n`;
markdown += `3. 添加文件头部注释(\`@name\` 和参考资料)\n`;
markdown += `4. 使用 \`forwardRef(function ComponentName(props: AxureProps, ref: React.ForwardedRef<AxureHandle>) { ... })\` 包装\n`;
markdown += `5. 实现 \`useImperativeHandle\`\n`;
markdown += `6. 保持原有的 JSX、Hooks 和 Tailwind 类名不变\n\n`;
} else {
markdown += `⚠️ 未找到 \`App.tsx\`,请手动创建 \`index.tsx\`\n\n`;
}
markdown += `### 任务 2: 创建 style.css\n\n`;
markdown += `**目标**: 提取 index.html 中的样式信息\n\n`;
if (report.indexHtml) {
markdown += `**操作**:\n`;
markdown += `1. 创建 \`style.css\`,开头添加 \`@import "tailwindcss";\`\n`;
if (report.indexHtml.customStyles.length > 0) {
markdown += `2. 从 \`index.html\`\`<style>\` 标签提取自定义样式\n`;
markdown += `3. 将提取的样式添加到 \`style.css\`\n`;
}
if (report.indexHtml.fonts.length > 0) {
markdown += `4. 添加外部字体引用:\n`;
report.indexHtml.fonts.forEach(font => {
markdown += ` \`@import url('${font}');\`\n`;
});
}
markdown += `\n`;
} else {
markdown += `**操作**: 创建基础样式文件,内容为 \`@import "tailwindcss";\`\n\n`;
}
markdown += `### 任务 3: 移除 AI Studio 特定文件\n\n`;
markdown += `**目标**: 删除不需要的文件\n\n`;
markdown += `**需要删除**:\n`;
if (report.structure.hasIndexHtml) {
markdown += `- ✓ \`index.html\` (已提取信息)\n`;
}
if (report.structure.hasIndexTsx) {
markdown += `- ✓ \`index.tsx\` (本项目有自己的入口)\n`;
}
if (report.structure.hasViteConfig) {
markdown += `- ⚠️ \`vite.config.ts\` (检查路径别名后可删除)\n`;
}
if (report.structure.hasMetadataJson) {
markdown += `- ⚠️ \`metadata.json\` (可选保留作为参考)\n`;
}
markdown += `\n`;
markdown += `### 任务 4: 安装依赖\n\n`;
if (report.dependencies.toInstall && report.dependencies.toInstall.length > 0) {
markdown += `**执行命令**:\n`;
markdown += `\`\`\`bash\n`;
markdown += `pnpm add ${report.dependencies.toInstall.join(' ')}\n`;
markdown += `\`\`\`\n\n`;
if (report.dependencies.fromCDN) {
markdown += `**CDN 依赖映射**:\n`;
report.dependencies.fromCDN.forEach(dep => {
markdown += `- \`${dep}\` (从 Import Map 识别)\n`;
});
markdown += `\n`;
}
} else {
markdown += `✓ 无需安装额外依赖\n\n`;
}
if (report.viteConfig?.hasEnvVars) {
markdown += `**环境变量**:\n`;
markdown += `⚠️ 项目使用了环境变量,需要配置 \`.env.local\`\n`;
markdown += `\`\`\`\n${report.viteConfig.envVars}\n\`\`\`\n\n`;
}
markdown += `### 任务 5: 验收测试\n\n`;
markdown += `**执行命令**:\n`;
markdown += `\`\`\`bash\n`;
markdown += `node scripts/check-app-ready.mjs ${targetInfo.checkPath}\n`;
markdown += `\`\`\`\n\n`;
markdown += `**验收标准**: 状态为 READY页面正常渲染无控制台错误\n\n`;
markdown += `## 📚 参考资料\n\n`;
markdown += `- **转换规范**: \`/skills/ai-studio-project-converter/SKILL.md\`\n`;
markdown += `- **原始项目**: \`${tempDir}\` (仅供参考)\n`;
markdown += `- **详细数据**: \`${targetInfo.analysisFileName}\`\n\n`;
markdown += `## 💡 注意事项\n\n`;
markdown += `1. **Import Map**: CDN 依赖需转换为 npm 包\n`;
markdown += `2. **自定义样式**: 从 index.html 提取到 style.css\n`;
markdown += `3. **路径别名**: 检查 vite.config.ts 中的 alias 配置\n`;
markdown += `4. **环境变量**: \`process.env.*\` 需改为 \`import.meta.env.VITE_*\`\n`;
markdown += `5. **原始文件**: \`${tempDir}\` 目录保留作为参考,不要修改\n`;
markdown += `6. **验证**: 完成后务必运行验收脚本确认\n`;
const mdPath = path.join(targetInfo.outputDir, targetInfo.tasksFileName);
fs.writeFileSync(mdPath, markdown);
return { reportPath, mdPath };
}
function generateThemeTasksDocument(report, targetInfo, outputName, tempDir) {
const reportPath = path.join(targetInfo.outputDir, targetInfo.analysisFileName);
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
let markdown = `# AI Studio 主题导入任务清单\n\n`;
markdown += `> **重要**: 请先阅读 \`/skills/ai-studio-project-converter/SKILL.md\` 与主题拆分技能文档,按任务顺序执行\n\n`;
markdown += `**主题 key**: ${outputName}\n`;
markdown += `**主题目录**: \`${targetInfo.relativeOutputDir}/\`\n`;
markdown += `**原始文件**: \`${tempDir}\` (仅供参考,不要修改)\n`;
markdown += `**生成时间**: ${new Date().toLocaleString()}\n\n`;
markdown += `## 📊 输入概况\n\n`;
markdown += `- 总文件数: ${report.summary.totalFiles}\n`;
markdown += `- 组件数: ${report.summary.componentCount}\n`;
markdown += `- Import Map: ${report.summary.hasImportMap ? '✓' : '✗'}\n`;
markdown += `- 自定义样式: ${report.summary.hasCustomStyles ? '✓' : '✗'}\n`;
markdown += `- 待评估依赖: ${report.summary.dependenciesToInstall}\n\n`;
markdown += `## 📚 参考文档(必须阅读)\n\n`;
markdown += `- \`/skills/ai-studio-project-converter/SKILL.md\`\n`;
THEME_SPLIT_SKILL_DOCS.forEach((docPath) => {
markdown += `- \`${docPath}\`\n`;
});
markdown += `\n`;
markdown += `## ✅ 主题导入任务(共 5 个)\n\n`;
markdown += `### 任务 1生成主题 token\n\n`;
markdown += `**目标**:在 \`${targetInfo.relativeOutputDir}/\` 下生成 \`globals.css\`\`designToken.json\`(二选一)\n\n`;
markdown += `**要求**\n`;
markdown += `- 若输出 \`designToken.json\`,必须包含 \`name\` 字段\n`;
markdown += `- 结合 AI Studio 项目中的配色、字体和组件样式提炼 token\n\n`;
markdown += `### 任务 2生成 DESIGN-SPEC.md\n\n`;
markdown += `**目标**:输出 \`${targetInfo.relativeOutputDir}/DESIGN-SPEC.md\`\n\n`;
markdown += `**要求**:说明视觉语言、组件规范、排版层级、交互状态与适配策略\n\n`;
markdown += `### 任务 3按需生成项目文档\n\n`;
markdown += `**目标**:在 \`src/docs/\` 下补充主题相关文档(如页面地图、项目概览)\n\n`;
markdown += `**要求**:保证文档与主题规范和业务上下文一致\n\n`;
markdown += `### 任务 4按需生成数据模型\n\n`;
markdown += `**目标**:在 \`src/database/\` 下补充或更新数据模型\n\n`;
markdown += `**要求**\n`;
markdown += `- 文件名英文、\`tableName\` 中文\n`;
markdown += `- 每个表包含 \`records\` 数组,记录 id 唯一\n\n`;
markdown += `### 任务 5生成/更新主题演示入口\n\n`;
markdown += `**目标**:生成或更新 \`${targetInfo.relativeOutputDir}/index.tsx\`\n\n`;
markdown += `**要求**:能清晰展示主题样式与 token 使用方式\n\n`;
markdown += `## 🔍 验收建议\n\n`;
markdown += `- 目录检查:\`${targetInfo.relativeOutputDir}/\` 是否包含 token 文件、\`DESIGN-SPEC.md\`\`index.tsx\`\n`;
markdown += `- 文档检查:\`src/docs/\` 是否按需补充\n`;
markdown += `- 数据检查:\`src/database/\` JSON 结构是否满足约束\n\n`;
markdown += `## 📎 产物索引\n\n`;
markdown += `- 任务清单:\`${targetInfo.tasksFileName}\`\n`;
markdown += `- 分析报告:\`${targetInfo.analysisFileName}\`\n`;
const mdPath = path.join(targetInfo.outputDir, targetInfo.tasksFileName);
fs.writeFileSync(mdPath, markdown);
return { reportPath, mdPath };
}
function parseArgs(rawArgs) {
const args = [...rawArgs];
const help = args.length === 0 || args.includes('--help') || args.includes('-h');
if (help) {
return { help: true };
}
let projectDirArg = '';
let outputNameArg = '';
let targetType = 'prototypes';
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === '--target-type') {
const nextValue = args[index + 1];
if (!nextValue) {
throw new Error('参数 --target-type 缺少值');
}
targetType = String(nextValue).trim();
index += 1;
continue;
}
if (!projectDirArg) {
projectDirArg = arg;
continue;
}
if (!outputNameArg) {
outputNameArg = arg;
continue;
}
}
if (!projectDirArg) {
throw new Error('缺少 <ai-studio-project-dir> 参数');
}
if (!Object.prototype.hasOwnProperty.call(TARGET_TYPE_TO_SRC_DIR, targetType)) {
throw new Error(`不支持的 targetType: ${targetType}。可选值: ${Object.keys(TARGET_TYPE_TO_SRC_DIR).join(', ')}`);
}
const outputName = sanitizeName(outputNameArg || path.basename(projectDirArg));
if (!outputName) {
throw new Error('无法生成有效的输出名称,请显式传入 [output-name]');
}
return {
help: false,
projectDirArg,
outputName,
targetType,
};
}
function printHelp() {
console.log(`
AI Studio 项目预处理器
使用方法:
node scripts/ai-studio-converter.mjs <ai-studio-project-dir> [output-name] [--target-type <prototypes|components|themes>]
示例:
node scripts/ai-studio-converter.mjs "temp/my-ai-studio-project" my-page
node scripts/ai-studio-converter.mjs "temp/my-ai-studio-project" brand-theme --target-type themes
功能:
- 完整复制 AI Studio 项目(不修改代码)
- 生成 AI 工作文档(默认 .ai-studio-tasks.md主题模式 .ai-studio-theme-tasks.md
- 生成分析报告(默认 .ai-studio-analysis.json主题模式 .ai-studio-theme-analysis.json
`);
}
async function main() {
let parsed;
try {
parsed = parseArgs(process.argv.slice(2));
} catch (error) {
log(`参数错误: ${error.message}`, 'error');
printHelp();
process.exit(1);
}
if (parsed.help) {
printHelp();
process.exit(0);
}
const aiStudioDir = path.resolve(CONFIG.projectRoot, parsed.projectDirArg);
const targetInfo = getTargetInfo(parsed.targetType, parsed.outputName);
const outputDir = targetInfo.outputDir;
if (!fs.existsSync(aiStudioDir)) {
log(`错误: 找不到目录 ${aiStudioDir}`, 'error');
process.exit(1);
}
const appTsx = path.join(aiStudioDir, 'App.tsx');
const indexHtml = path.join(aiStudioDir, 'index.html');
if (!fs.existsSync(appTsx) && !fs.existsSync(indexHtml)) {
log('错误: 这不是一个有效的 AI Studio 项目(缺少 App.tsx 或 index.html', 'error');
process.exit(1);
}
try {
ensureDir(targetInfo.outputBaseDir);
log(`开始预处理 AI Studio 项目targetType=${parsed.targetType}...`, 'info');
log('步骤 1/4: 复制项目文件...', 'progress');
const fileCount = copyDirectory(aiStudioDir, outputDir);
log(`已复制 ${fileCount} 个文件`, 'info');
log('步骤 2/4: 复制 assets 到目标目录...', 'progress');
const assetsDir = path.join(aiStudioDir, 'assets');
const outputAssetsDir = path.join(outputDir, 'assets');
let assetCount = 0;
if (fs.existsSync(assetsDir)) {
assetCount = copyDirectory(assetsDir, outputAssetsDir);
log(`已复制 ${assetCount} 个资源文件到 ${targetInfo.relativeOutputDir}/assets/`, 'info');
} else {
log('未找到 assets 目录,跳过', 'info');
}
log('步骤 3/4: 分析项目结构...', 'progress');
const analysis = analyzeProject(outputDir);
log(`发现 ${analysis.components.length} 个组件`, 'info');
log('步骤 4/4: 生成任务文档...', 'progress');
const report = buildAnalysisReport(analysis);
const { reportPath, mdPath } = parsed.targetType === 'themes'
? generateThemeTasksDocument(report, targetInfo, parsed.outputName, `temp/${path.basename(aiStudioDir)}`)
: generateDefaultTasksDocument(report, targetInfo, parsed.outputName, `temp/${path.basename(aiStudioDir)}`);
log('✅ 预处理完成!', 'info');
log('', 'info');
log(`📁 ${targetInfo.label}位置: ${targetInfo.relativeOutputDir}/`, 'info');
log(`📋 AI 工作文档: ${path.relative(CONFIG.projectRoot, mdPath)}`, 'info');
log(`📊 详细数据: ${path.relative(CONFIG.projectRoot, reportPath)}`, 'info');
log('', 'info');
log('📈 统计:', 'info');
log(` - 文件数: ${analysis.files.length}`, 'info');
log(` - 组件数: ${analysis.components.length}`, 'info');
log(` - 依赖: ${analysis.dependencies.toInstall?.length || 0}`, 'info');
log('', 'info');
log('🎯 下一步:', 'info');
log(`1. 查看任务文档: cat ${path.relative(CONFIG.projectRoot, mdPath)}`, 'info');
log(parsed.targetType === 'themes'
? '2. 让 AI 按任务单完成主题/文档/数据生成'
: '2. 让 AI 根据任务清单完成转换', 'info');
} catch (error) {
log(`预处理失败: ${error.message}`, 'error');
console.error(error);
process.exit(1);
}
}
main();

View File

@@ -0,0 +1,48 @@
import { spawnSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { scanProjectEntries, writeEntriesManifestAtomic } from '../vite-plugins/utils/entriesManifestCore.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const workspaceRoot = path.resolve(__dirname, '..');
const entriesPath = path.resolve(workspaceRoot, '.axhub/make/entries.json');
// Always rescan before build to keep entries manifest fresh and deterministic.
const scanned = scanProjectEntries(workspaceRoot, ['components', 'prototypes', 'themes']);
const entries = writeEntriesManifestAtomic(workspaceRoot, scanned);
if (!fs.existsSync(entriesPath)) {
console.error('.axhub/make/entries.json 写入失败,无法继续构建。');
process.exit(1);
}
const jsEntries = entries.js || {};
const entryKeys = Object.keys(jsEntries);
if (entryKeys.length === 0) {
console.log('未发现 JS 入口,跳过构建。');
process.exit(0);
}
const distDir = path.resolve(workspaceRoot, 'dist');
if (fs.existsSync(distDir)) {
fs.rmSync(distDir, { recursive: true, force: true });
}
for (const key of entryKeys) {
console.log(`\n==== 构建入口: ${key} ====\n`);
const result = spawnSync('npx', ['vite', 'build'], {
cwd: workspaceRoot,
env: { ...process.env, ENTRY_KEY: key },
stdio: 'inherit'
});
if (result.status !== 0) {
console.error(`构建 ${key} 失败,退出码 ${result.status}`);
process.exit(result.status ?? 1);
}
}
console.log('\n所有入口构建完成 ✅');

View File

@@ -0,0 +1,778 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import zlib from 'node:zlib';
import { decodeBinarySchema, compileSchema } from '../../../packages/axhub-export-core/node_modules/kiwi-schema/kiwi-esm.js';
import { inflateRaw } from '../../../packages/axhub-export-core/node_modules/pako/dist/pako.esm.mjs';
const PRELUDE_LENGTH = 8;
const VERSION_OFFSET = PRELUDE_LENGTH;
const PARTS_OFFSET = VERSION_OFFSET + 4;
const DEFAULT_SOURCE_ROOT = 'src';
const MANIFEST_FILENAME = 'canvas.code-manifest.json';
function printUsage() {
console.log(`Usage:
node scripts/canvas-fig-sync.mjs inspect --fig <canvas.fig> [--manifest <file>]
node scripts/canvas-fig-sync.mjs extract --fig <canvas.fig> --out <project-dir> [--source-root src] [--manifest <file>]
node scripts/canvas-fig-sync.mjs pack --fig <canvas.fig> --from <project-dir> [--source-root src] [--out <new-canvas.fig>] [--manifest <file>] [--prune-missing] [--sanitize-for-export]
`);
}
function parseArgs(argv) {
const [command, ...rest] = argv;
if (!command || command === '--help' || command === '-h') {
return { command: 'help', options: {} };
}
const options = {};
for (let index = 0; index < rest.length; index += 1) {
const token = rest[index];
if (!token.startsWith('--')) {
throw new Error(`Unexpected argument: ${token}`);
}
const key = token.slice(2);
const value = rest[index + 1];
if (!value || value.startsWith('--')) {
options[key] = true;
continue;
}
options[key] = value;
index += 1;
}
return { command, options };
}
function getRequiredOption(options, key) {
const value = options[key];
if (!value) {
throw new Error(`Missing required option --${key}`);
}
return value;
}
function resolvePath(value) {
return path.resolve(value);
}
function sha1(content) {
return crypto.createHash('sha1').update(content).digest('hex');
}
function toPosixPath(value) {
return value.split(path.sep).join(path.posix.sep);
}
function normalizeRelativePath(value) {
const normalized = path.posix.normalize(value).replace(/^\/+/, '');
if (!normalized || normalized === '.') {
return '';
}
if (normalized.startsWith('../') || normalized === '..') {
throw new Error(`Unsafe relative path: ${value}`);
}
return normalized;
}
function joinLogicalPath(codeFilePath, name) {
const basePath = codeFilePath ? normalizeRelativePath(toPosixPath(codeFilePath)) : '';
const fileName = normalizeRelativePath(name);
return basePath ? path.posix.join(basePath, fileName) : fileName;
}
function normalizeSourceRoot(sourceRoot) {
return normalizeRelativePath(toPosixPath(sourceRoot || DEFAULT_SOURCE_ROOT));
}
function guidToString(value) {
if (!value || typeof value !== 'object') {
return '';
}
if (value.guid && typeof value.guid === 'object') {
return guidToString(value.guid);
}
if (typeof value.sessionID === 'number' && typeof value.localID === 'number') {
return `${value.sessionID}:${value.localID}`;
}
return '';
}
function createCollaborativeSourceCode(sourceCode, sessionID = 1) {
const contentBuffer = Uint8Array.from(Buffer.from(sourceCode, 'utf8'));
return {
historyOpsWithIds: [
{
firstId: { sessionID, counterID: 1 },
runLength: sourceCode.length,
parentIds: [],
},
],
historyOpsWithLoc: [
{
type: 'INSERT',
range: { startIndex: 0, endIndexExclusive: sourceCode.length },
contentBytesInBuffer: { startIndex: 0, endIndexExclusive: contentBuffer.length },
},
],
historyStringContentBuffer: contentBuffer,
};
}
function resolveProjectFilePath(projectDir, sourceRoot, logicalPath) {
const root = normalizeSourceRoot(sourceRoot);
const relativePath = root ? path.posix.join(root, logicalPath) : logicalPath;
return path.resolve(projectDir, ...relativePath.split('/'));
}
function ensureParentDirectory(filePath) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
}
function parseArchive(buffer) {
if (buffer.byteLength < PARTS_OFFSET) {
throw new Error('Archive is too small.');
}
const prelude = Buffer.from(buffer.subarray(0, PRELUDE_LENGTH)).toString('utf8');
const version = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength).getUint32(
VERSION_OFFSET,
true,
);
const parts = [];
let offset = PARTS_OFFSET;
while (offset + 4 <= buffer.byteLength) {
const size = new DataView(buffer.buffer, buffer.byteOffset + offset, 4).getUint32(0, true);
offset += 4;
if (offset + size > buffer.byteLength) {
throw new Error(`Invalid archive bounds at offset ${offset}.`);
}
parts.push(buffer.subarray(offset, offset + size));
offset += size;
}
return { prelude, version, parts };
}
function encodeArchive({ prelude, version, parts }) {
const totalLength =
PARTS_OFFSET + parts.reduce((sum, part) => sum + 4 + Buffer.byteLength(part), 0);
const output = Buffer.alloc(totalLength);
output.write(prelude, 0, PRELUDE_LENGTH, 'utf8');
output.writeUInt32LE(version, VERSION_OFFSET);
let offset = PARTS_OFFSET;
for (const part of parts) {
const buffer = Buffer.from(part);
output.writeUInt32LE(buffer.length, offset);
offset += 4;
buffer.copy(output, offset);
offset += buffer.length;
}
return output;
}
function loadCanvasFig(figPath) {
const archiveBytes = new Uint8Array(fs.readFileSync(figPath));
const { prelude, version, parts } = parseArchive(archiveBytes);
if (prelude !== 'fig-make') {
throw new Error(`Unsupported prelude: ${prelude}`);
}
if (parts.length !== 2) {
throw new Error(`Expected 2 archive parts, got ${parts.length}`);
}
const schemaPart = parts[0];
const messagePart = parts[1];
const schemaBytes = inflateRaw(schemaPart);
const schema = decodeBinarySchema(schemaBytes);
const compiled = compileSchema(schema);
const messageBytes = new Uint8Array(zlib.zstdDecompressSync(Buffer.from(messagePart)));
const message = compiled.decodeMessage(messageBytes);
return {
figPath,
prelude,
version,
schemaPart,
compiled,
message,
};
}
function buildCodeFileEntries(message) {
const codeEntries = [];
for (const [nodeChangeIndex, node] of (message.nodeChanges || []).entries()) {
if (node?.type !== 'CODE_FILE') {
continue;
}
const name = node.name || `unnamed-${nodeChangeIndex}`;
const codeFilePath = node.codeFilePath || '';
const logicalPath = joinLogicalPath(codeFilePath, name);
const sourceCode = node.sourceCode || '';
codeEntries.push({
nodeChangeIndex,
node,
name,
codeFilePath,
logicalPath,
sourceCode,
sourceCodeSha1: sha1(sourceCode),
});
}
const duplicateMap = new Map();
for (const entry of codeEntries) {
duplicateMap.set(entry.logicalPath, (duplicateMap.get(entry.logicalPath) || 0) + 1);
}
return codeEntries.map((entry) => ({
...entry,
isDuplicate: (duplicateMap.get(entry.logicalPath) || 0) > 1,
duplicateCount: duplicateMap.get(entry.logicalPath) || 1,
}));
}
function summarizeEntries(entries) {
const pathCounts = new Map();
const duplicateGroups = [];
const grouped = new Map();
for (const entry of entries) {
const codeFilePath = entry.codeFilePath || '(root)';
pathCounts.set(codeFilePath, (pathCounts.get(codeFilePath) || 0) + 1);
if (!grouped.has(entry.logicalPath)) {
grouped.set(entry.logicalPath, []);
}
grouped.get(entry.logicalPath).push(entry.nodeChangeIndex);
}
for (const [logicalPath, indices] of grouped.entries()) {
if (indices.length > 1) {
duplicateGroups.push({
logicalPath,
nodeChangeIndices: indices,
});
}
}
return {
totalCodeFiles: entries.length,
pathCounts: Object.fromEntries([...pathCounts.entries()].sort(([a], [b]) => a.localeCompare(b))),
duplicateGroups,
};
}
function collectCodeGraph(message) {
const codeFiles = [];
const codeFilesByGuid = new Map();
const codeFilesByLogicalPath = new Map();
for (const [nodeChangeIndex, node] of (message.nodeChanges || []).entries()) {
if (node?.type !== 'CODE_FILE') {
continue;
}
const logicalPath = joinLogicalPath(node.codeFilePath || '', node.name || `unnamed-${nodeChangeIndex}`);
const guid = guidToString(node.guid);
const codeFile = {
nodeChangeIndex,
node,
guid,
logicalPath,
};
codeFiles.push(codeFile);
if (guid) {
codeFilesByGuid.set(guid, codeFile);
}
if (!codeFilesByLogicalPath.has(logicalPath)) {
codeFilesByLogicalPath.set(logicalPath, []);
}
codeFilesByLogicalPath.get(logicalPath).push(codeFile);
}
return {
codeFiles,
codeFilesByGuid,
codeFilesByLogicalPath,
};
}
function listRelativeImportSpecifiers(sourceCode) {
const specifiers = new Set();
const patterns = [
/import\s+[^'"]*?from\s+['"]([^'"]+)['"]/g,
/import\s*['"]([^'"]+)['"]/g,
/export\s+[^'"]*?from\s+['"]([^'"]+)['"]/g,
/import\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
];
for (const pattern of patterns) {
pattern.lastIndex = 0;
for (let match = pattern.exec(sourceCode); match; match = pattern.exec(sourceCode)) {
const specifier = match[1];
if (specifier && specifier.startsWith('.')) {
specifiers.add(specifier);
}
}
}
return [...specifiers];
}
function resolveImportToLogicalPath(fromLogicalPath, specifier, availableLogicalPaths) {
const baseDir = path.posix.dirname(fromLogicalPath);
const normalizedBaseDir = baseDir === '.' ? '' : baseDir;
const resolvedBase = path.posix.normalize(
normalizedBaseDir ? path.posix.join(normalizedBaseDir, specifier) : specifier,
);
const candidates = [
resolvedBase,
`${resolvedBase}.ts`,
`${resolvedBase}.tsx`,
`${resolvedBase}.js`,
`${resolvedBase}.jsx`,
`${resolvedBase}.css`,
path.posix.join(resolvedBase, 'index.ts'),
path.posix.join(resolvedBase, 'index.tsx'),
path.posix.join(resolvedBase, 'index.js'),
path.posix.join(resolvedBase, 'index.jsx'),
path.posix.join(resolvedBase, 'index.css'),
];
for (const candidate of candidates) {
const normalized = normalizeRelativePath(candidate);
if (availableLogicalPaths.has(normalized)) {
return normalized;
}
}
return null;
}
function sanitizeForExport(message) {
const warnings = [];
const codeGraph = collectCodeGraph(message);
const availableLogicalPaths = new Set(codeGraph.codeFiles.map((codeFile) => codeFile.logicalPath));
const existingCodeFileGuids = new Set(codeGraph.codeFiles.map((codeFile) => codeFile.guid).filter(Boolean));
let clearedChatMessageCount = 0;
let clearedLibraryCount = 0;
let rebuiltImportReferenceCount = 0;
let prunedDanglingImportReferenceCount = 0;
let prunedCodeComponentCount = 0;
let clearedCodeInstanceSnapshotCount = 0;
for (const node of message.nodeChanges || []) {
if (node?.type !== 'CODE_LIBRARY') {
continue;
}
const chatMessages = Array.isArray(node.chatMessages) ? node.chatMessages : [];
if (chatMessages.length > 0) {
clearedChatMessageCount += chatMessages.length;
node.chatMessages = [];
}
if (node.chatCompressionState !== undefined) {
delete node.chatCompressionState;
}
clearedLibraryCount += 1;
}
for (const node of message.nodeChanges || []) {
if (node?.type !== 'CODE_INSTANCE') {
continue;
}
if (node.codeSnapshot !== undefined) {
delete node.codeSnapshot;
clearedCodeInstanceSnapshotCount += 1;
}
}
for (const codeFile of codeGraph.codeFiles) {
const importedLogicalPaths = [];
for (const specifier of listRelativeImportSpecifiers(codeFile.node.sourceCode || '')) {
const resolved = resolveImportToLogicalPath(codeFile.logicalPath, specifier, availableLogicalPaths);
if (resolved) {
importedLogicalPaths.push(resolved);
continue;
}
warnings.push(`Unresolved relative import ${specifier} in ${codeFile.logicalPath}; omitted from importedCodeFiles.`);
}
const uniqueImportedPaths = [...new Set(importedLogicalPaths)].filter(
(logicalPath) => logicalPath !== codeFile.logicalPath,
);
const nextEntries = [];
for (const logicalPath of uniqueImportedPaths) {
const target = codeGraph.codeFilesByLogicalPath.get(logicalPath)?.[0];
if (!target?.guid) {
continue;
}
nextEntries.push({
codeFileId: {
guid: target.node.guid,
},
});
}
const previousEntries = Array.isArray(codeFile.node.importedCodeFiles?.entries)
? codeFile.node.importedCodeFiles.entries
: [];
const previousGuidCount = previousEntries.filter((entry) => existingCodeFileGuids.has(guidToString(entry?.codeFileId))).length;
prunedDanglingImportReferenceCount += Math.max(0, previousEntries.length - previousGuidCount);
rebuiltImportReferenceCount += nextEntries.length;
if (nextEntries.length > 0) {
codeFile.node.importedCodeFiles = { entries: nextEntries };
} else {
delete codeFile.node.importedCodeFiles;
}
}
message.nodeChanges = (message.nodeChanges || []).filter((node) => {
if (node?.type !== 'CODE_COMPONENT') {
return true;
}
const exportedFromGuid = guidToString(node.exportedFromCodeFileId);
if (exportedFromGuid && existingCodeFileGuids.has(exportedFromGuid)) {
return true;
}
prunedCodeComponentCount += 1;
warnings.push(`Pruned CODE_COMPONENT ${node.name || '(unnamed)'} because exportedFromCodeFileId no longer exists.`);
return false;
});
return {
warnings,
clearedChatMessageCount,
clearedLibraryCount,
clearedCodeInstanceSnapshotCount,
rebuiltImportReferenceCount,
prunedDanglingImportReferenceCount,
prunedCodeComponentCount,
};
}
function buildBaseManifest(command, figData, entries, sourceRoot) {
const summary = summarizeEntries(entries);
return {
command,
generatedAt: new Date().toISOString(),
figPath: figData.figPath,
archive: {
prelude: figData.prelude,
version: figData.version,
parts: 2,
},
sourceRoot: normalizeSourceRoot(sourceRoot),
summary,
};
}
function defaultManifestPath(command, figPath, options) {
if (options.manifest) {
return resolvePath(options.manifest);
}
const figBaseName = path.basename(
command === 'pack' ? resolvePath(options.out || options.fig) : resolvePath(options.fig),
path.extname(command === 'pack' ? resolvePath(options.out || options.fig) : resolvePath(options.fig)),
);
if (command === 'extract') {
return path.resolve(resolvePath(options.out), MANIFEST_FILENAME);
}
const baseDir =
command === 'pack'
? path.dirname(resolvePath(options.out || options.fig))
: path.dirname(resolvePath(options.fig));
return path.resolve(baseDir, `${figBaseName}.code-manifest.json`);
}
function writeManifest(manifestPath, manifest) {
ensureParentDirectory(manifestPath);
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
}
function extractCommand(options) {
const figPath = resolvePath(getRequiredOption(options, 'fig'));
const outputDir = resolvePath(getRequiredOption(options, 'out'));
const sourceRoot = options['source-root'] || DEFAULT_SOURCE_ROOT;
const manifestPath = defaultManifestPath('extract', figPath, options);
const figData = loadCanvasFig(figPath);
const entries = buildCodeFileEntries(figData.message);
const latestByLogicalPath = new Map();
for (const entry of entries) {
latestByLogicalPath.set(entry.logicalPath, entry);
}
const manifestEntries = [];
for (const entry of entries) {
const outputFilePath = resolveProjectFilePath(outputDir, sourceRoot, entry.logicalPath);
const isLatest = latestByLogicalPath.get(entry.logicalPath)?.nodeChangeIndex === entry.nodeChangeIndex;
const status = isLatest ? 'written' : 'shadowed-by-later-duplicate';
if (isLatest) {
ensureParentDirectory(outputFilePath);
fs.writeFileSync(outputFilePath, entry.sourceCode, 'utf8');
}
manifestEntries.push({
nodeChangeIndex: entry.nodeChangeIndex,
name: entry.name,
codeFilePath: entry.codeFilePath || null,
logicalPath: entry.logicalPath,
sourceCodeSha1: entry.sourceCodeSha1,
isDuplicate: entry.isDuplicate,
duplicateCount: entry.duplicateCount,
extractedPath: outputFilePath,
extractStatus: status,
});
}
const manifest = {
...buildBaseManifest('extract', figData, entries, sourceRoot),
outputDirectory: outputDir,
entries: manifestEntries,
};
writeManifest(manifestPath, manifest);
const duplicateCount = manifest.summary.duplicateGroups.length;
console.log(`Extracted ${manifest.summary.totalCodeFiles} CODE_FILE nodes from ${path.basename(figPath)}`);
console.log(`Source root: ${normalizeSourceRoot(sourceRoot) || '.'}`);
console.log(`Output directory: ${outputDir}`);
console.log(`Manifest: ${manifestPath}`);
if (duplicateCount > 0) {
console.warn(`Warning: ${duplicateCount} duplicate logical path group(s) were resolved with last-wins semantics.`);
}
}
function inspectCommand(options) {
const figPath = resolvePath(getRequiredOption(options, 'fig'));
const manifestPath = defaultManifestPath('inspect', figPath, options);
const figData = loadCanvasFig(figPath);
const entries = buildCodeFileEntries(figData.message);
const manifest = {
...buildBaseManifest('inspect', figData, entries, DEFAULT_SOURCE_ROOT),
entries: entries.map((entry) => ({
nodeChangeIndex: entry.nodeChangeIndex,
name: entry.name,
codeFilePath: entry.codeFilePath || null,
logicalPath: entry.logicalPath,
sourceCodeSha1: entry.sourceCodeSha1,
isDuplicate: entry.isDuplicate,
duplicateCount: entry.duplicateCount,
})),
};
writeManifest(manifestPath, manifest);
console.log(`FIG: ${figPath}`);
console.log(`Prelude: ${figData.prelude}`);
console.log(`Version: ${figData.version}`);
console.log(`CODE_FILE nodes: ${manifest.summary.totalCodeFiles}`);
console.log('Path distribution:');
for (const [codeFilePath, count] of Object.entries(manifest.summary.pathCounts)) {
console.log(` ${codeFilePath}: ${count}`);
}
if (manifest.summary.duplicateGroups.length > 0) {
console.log('Duplicate logical paths:');
for (const duplicate of manifest.summary.duplicateGroups) {
console.log(` ${duplicate.logicalPath} -> [${duplicate.nodeChangeIndices.join(', ')}]`);
}
} else {
console.log('Duplicate logical paths: none');
}
console.log(`Manifest: ${manifestPath}`);
}
function packCommand(options) {
const figPath = resolvePath(getRequiredOption(options, 'fig'));
const projectDir = resolvePath(getRequiredOption(options, 'from'));
const sourceRoot = options['source-root'] || DEFAULT_SOURCE_ROOT;
const outputFigPath = resolvePath(options.out || figPath);
const manifestPath = defaultManifestPath('pack', outputFigPath, options);
const pruneMissing = options['prune-missing'] === true || options['prune-missing'] === 'true';
const sanitizeForExportMode =
options['sanitize-for-export'] === true || options['sanitize-for-export'] === 'true';
const figData = loadCanvasFig(figPath);
const entries = buildCodeFileEntries(figData.message);
const byLogicalPath = new Map();
for (const entry of entries) {
if (!byLogicalPath.has(entry.logicalPath)) {
byLogicalPath.set(entry.logicalPath, []);
}
byLogicalPath.get(entry.logicalPath).push(entry);
}
const manifestEntries = [];
const warnings = [];
const updatedLogicalPaths = new Set();
const prunedLogicalPaths = new Set();
const prunedNodeChangeIndices = new Set();
for (const [logicalPath, group] of byLogicalPath.entries()) {
const projectFilePath = resolveProjectFilePath(projectDir, sourceRoot, logicalPath);
const exists = fs.existsSync(projectFilePath);
if (!exists) {
if (pruneMissing) {
warnings.push(`Missing source file for ${logicalPath}; pruned ${group.length} CODE_FILE node(s).`);
prunedLogicalPaths.add(logicalPath);
for (const entry of group) {
prunedNodeChangeIndices.add(entry.nodeChangeIndex);
manifestEntries.push({
nodeChangeIndex: entry.nodeChangeIndex,
name: entry.name,
codeFilePath: entry.codeFilePath || null,
logicalPath,
sourceCodeSha1: entry.sourceCodeSha1,
isDuplicate: entry.isDuplicate,
duplicateCount: entry.duplicateCount,
packedPath: projectFilePath,
packStatus: 'pruned-missing-file',
});
}
continue;
}
warnings.push(`Missing source file for ${logicalPath}; preserved original canvas.fig content.`);
for (const entry of group) {
manifestEntries.push({
nodeChangeIndex: entry.nodeChangeIndex,
name: entry.name,
codeFilePath: entry.codeFilePath || null,
logicalPath,
sourceCodeSha1: entry.sourceCodeSha1,
isDuplicate: entry.isDuplicate,
duplicateCount: entry.duplicateCount,
packedPath: projectFilePath,
packStatus: 'preserved-missing-file',
});
}
continue;
}
const nextSource = fs.readFileSync(projectFilePath, 'utf8');
const nextSha1 = sha1(nextSource);
if (group.length > 1) {
warnings.push(`Duplicate logical path ${logicalPath} updated across ${group.length} CODE_FILE nodes.`);
}
for (const entry of group) {
entry.node.sourceCode = nextSource;
entry.node.collaborativeSourceCode = createCollaborativeSourceCode(
nextSource,
entry.node.guid?.sessionID ?? 1,
);
updatedLogicalPaths.add(logicalPath);
manifestEntries.push({
nodeChangeIndex: entry.nodeChangeIndex,
name: entry.name,
codeFilePath: entry.codeFilePath || null,
logicalPath,
sourceCodeSha1: nextSha1,
isDuplicate: entry.isDuplicate,
duplicateCount: entry.duplicateCount,
packedPath: projectFilePath,
packStatus: 'updated-from-disk',
});
}
}
if (pruneMissing && prunedNodeChangeIndices.size > 0) {
figData.message.nodeChanges = (figData.message.nodeChanges || []).filter((_, index) => !prunedNodeChangeIndices.has(index));
}
let exportSanitization = null;
if (sanitizeForExportMode) {
exportSanitization = sanitizeForExport(figData.message);
warnings.push(...exportSanitization.warnings);
}
const encodedMessage = figData.compiled.encodeMessage(figData.message);
const compressedMessage = zlib.zstdCompressSync(Buffer.from(encodedMessage));
const encodedArchive = encodeArchive({
prelude: figData.prelude,
version: figData.version,
parts: [figData.schemaPart, compressedMessage],
});
ensureParentDirectory(outputFigPath);
fs.writeFileSync(outputFigPath, encodedArchive);
const finalEntries = buildCodeFileEntries(figData.message);
const manifest = {
...buildBaseManifest('pack', figData, finalEntries, sourceRoot),
projectDirectory: projectDir,
outputFigPath,
updatedLogicalPathCount: updatedLogicalPaths.size,
prunedLogicalPathCount: prunedLogicalPaths.size,
sanitizeForExport: exportSanitization,
warnings,
entries: manifestEntries,
};
writeManifest(manifestPath, manifest);
console.log(`Packed ${updatedLogicalPaths.size} logical path(s) into ${outputFigPath}`);
if (prunedLogicalPaths.size > 0) {
console.log(`Pruned ${prunedLogicalPaths.size} logical path(s) without source files.`);
}
console.log(`Manifest: ${manifestPath}`);
if (warnings.length > 0) {
for (const warning of warnings) {
console.warn(`Warning: ${warning}`);
}
}
}
function main() {
try {
const { command, options } = parseArgs(process.argv.slice(2));
switch (command) {
case 'help':
printUsage();
return;
case 'inspect':
inspectCommand(options);
return;
case 'extract':
extractCommand(options);
return;
case 'pack':
packCommand(options);
return;
default:
throw new Error(`Unknown command: ${command}`);
}
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
printUsage();
process.exit(1);
}
}
main();

View File

@@ -0,0 +1,879 @@
#!/usr/bin/env node
/**
* =====================================================
* CLI: check-app-ready
*
* 功能:
* - AI 调用,检测 Vite dev server 和页面状态
* - 不依赖页面注入
* - 捕获已存在和后续构建/热更新错误
* - 页面可访问即 READY出现错误即 ERROR
* - 超时返回 TIMEOUT
* - 默认包含构建校验,可通过 --skip-build 跳过
*
* 使用:
* node scripts/check-app-ready.mjs [页面路径]
* 例如node scripts/check-app-ready.mjs /components/button
* node scripts/check-app-ready.mjs /prototypes/home
*
* 跳过构建校验:
* node scripts/check-app-ready.mjs --skip-build /components/button
*
* 输出JSON
* {
* status: "READY" | "ERROR" | "TIMEOUT",
* phase: "server|build|page|done",
* message: "...",
* url: "http://localhost:51720/components/button",
* errors: [...],
* logs: [...],
* buildCheck?: { status: "SUCCESS" | "FAILED" | "SKIPPED", errors: [...], logs: [...] }
* lintCheck?: { status: "SUCCESS" | "FAILED" | "SKIPPED", errors: [...], logs: [...] }
* typeCheck?: { status: "SUCCESS" | "FAILED" | "SKIPPED", errors: [...], logs: [...] }
* checks?: [{ name: "lint|typecheck|build", status: "...", message: "...", errors: [...] }]
* homeUrl?: "http://localhost:51720"
* targetUrl?: "http://localhost:51720/components/button"
* targetPath?: "http://localhost:51720/prototypes/ref-app-home/index.html"
* }
* =====================================================
*/
import { spawn } from 'node:child_process'
import { setTimeout as sleep } from 'node:timers/promises'
import process from 'node:process'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { decodeOutput, getPreferredNpmCommand, getPreferredNpxCommand } from './utils/command-runtime.mjs'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const APP_ROOT = path.resolve(__dirname, '..')
/* ================= 配置 ================= */
// 解析命令行参数
const args = process.argv.slice(2);
const skipBuild = args.includes('--skip-build');
const pagePath = args.find(arg => !arg.startsWith('--')) || '/';
const CONFIG = {
devCommand: ['run', 'dev'], // 启动 Vite 的命令参数
devServerInfoPath: path.resolve(__dirname, '../.axhub/make/.dev-server-info.json'), // 开发服务器信息文件
pagePath, // 目标页面路径(从命令行参数获取)
pollIntervalMs: 500, // 页面轮询间隔
stableCheckMs: 1000, // 错误稳定判断时间
timeoutMs: 30_000, // 总超时
skipBuild // 是否跳过构建校验
}
/* ================= 工具函数 ================= */
function jsonExit(payload, code = 0) {
process.stdout.write(JSON.stringify(payload, null, 2))
process.exit(code)
}
/**
* 尝试通过 HTTP 请求获取页面内容,检查是否有错误信息
*/
async function checkPageForErrors(url) {
try {
const res = await fetch(url, { method: 'GET' })
const text = await res.text()
// Only treat the HTML as an error page when Vite's overlay is present.
// The app template includes global error handlers with object literals like
// `{ error: event.error }`, which would otherwise cause false positives.
const hasViteOverlay =
text.includes('vite-error-overlay') ||
text.includes('__vite_error_overlay__') ||
/\[plugin:vite:/i.test(text) ||
/Transform failed/i.test(text)
if (!hasViteOverlay) return []
const errorPatterns = [
/\bError:\s*([^\n]+)/,
/\bSyntaxError:\s*([^\n]+)/,
/\bReferenceError:\s*([^\n]+)/,
/\[plugin:vite:[^\]]+\]\s*([^\n]+)/i,
/Transform failed/i
]
for (const pattern of errorPatterns) {
const match = text.match(pattern)
if (match) return [match[1] || match[0]]
}
return ['Detected Vite error overlay but could not extract message']
} catch (err) {
return []
}
}
async function isServerAlive(url) {
try {
const res = await fetch(url, { method: 'GET' })
return res.ok
} catch {
return false
}
}
/**
* 读取开发服务器信息
* 优先从 .axhub/make/.dev-server-info.json 读取实际运行的端口
*/
function getServerInfo() {
try {
if (fs.existsSync(CONFIG.devServerInfoPath)) {
const info = JSON.parse(fs.readFileSync(CONFIG.devServerInfoPath, 'utf8'))
return {
port: info.port,
host: info.host || 'localhost',
localIP: info.localIP || 'localhost'
}
}
} catch (err) {
logs.push(`Failed to read .axhub/make/.dev-server-info.json: ${err.message}`)
}
// 如果没有端口信息,返回 null 表示需要等待服务器启动
return null
}
/**
* 生成服务器首页 URL
* 使用 localhost 而不是 0.0.0.0,因为浏览器无法访问 0.0.0.0
*/
function getHomeUrl(serverInfo) {
// 如果 host 是 0.0.0.0,使用 localhost 替代
const host = serverInfo.host === '0.0.0.0' ? 'localhost' : serverInfo.host
return `http://${host}:${serverInfo.port}`
}
/**
* 获取可访问的 host
* 将 0.0.0.0 转换为 localhost因为浏览器无法直接访问 0.0.0.0
*/
function getAccessibleHost(serverInfo) {
return serverInfo.host === '0.0.0.0' ? 'localhost' : serverInfo.host
}
function getTargetUrl(serverInfo, targetPath) {
const host = getAccessibleHost(serverInfo)
return `http://${host}:${serverInfo.port}${targetPath}`
}
function getEntryHtmlPath(targetPath) {
const normalized = targetPath.startsWith('/') ? targetPath : `/${targetPath}`
if (normalized.endsWith('.html')) return normalized
if (normalized.endsWith('/')) return `${normalized}index.html`
return `${normalized}/index.html`
}
/* ================= 全局状态 ================= */
let logs = []
let errors = []
let lastErrorTime = 0
let errorCache = new Set() // 用于去重错误信息
/* ================= 阶段 1启动或 attach Vite ================= */
function startOrAttachVite() {
logs.push('Checking Vite server...')
const npmCommand = getPreferredNpmCommand()
const child = spawn(npmCommand, CONFIG.devCommand, {
stdio: ['ignore', 'pipe', 'pipe'],
cwd: APP_ROOT,
shell: false,
})
child.stdout.on('data', (data) => {
const text = decodeOutput(data).trim()
if (text) logs.push(text)
// 检测构建错误
if (/error/i.test(text) || /failed to compile/i.test(text)) {
errors.push(text)
lastErrorTime = Date.now()
}
})
child.stderr.on('data', (data) => {
const text = decodeOutput(data).trim()
if (text) {
// 过滤掉一些正常的警告信息
if (!/deprecated|experimental/i.test(text)) {
errors.push(text)
lastErrorTime = Date.now()
}
logs.push(text)
}
})
child.on('error', (err) => {
errors.push(`Process error: ${err.message}`)
lastErrorTime = Date.now()
})
return child
}
/* ================= 阶段 2轮询页面可访问性 ================= */
async function waitForPage(url) {
const start = Date.now()
let lastCheckTime = 0
while (Date.now() - start < CONFIG.timeoutMs) {
const now = Date.now()
// 每隔一段时间尝试获取错误信息(即使页面不可访问)
if (now - lastCheckTime > 2000) {
const pageErrors = await checkPageForErrors(url)
if (pageErrors.length > 0) {
// 去重:只添加未见过的错误
pageErrors.forEach(err => {
const errorKey = err.substring(0, 200) // 使用前200个字符作为唯一标识
if (!errorCache.has(errorKey)) {
errorCache.add(errorKey)
errors.push(err)
}
})
}
lastCheckTime = now
}
if (await isServerAlive(url)) return true
await sleep(CONFIG.pollIntervalMs)
}
return false
}
/* ================= 阶段 3等待稳定状态 ================= */
async function waitForStable(pageUrl) {
const startTime = Date.now()
while (Date.now() - startTime < CONFIG.timeoutMs) {
const now = Date.now()
// 页面可访问
const pageOk = await isServerAlive(pageUrl)
// 如果页面可访问,尝试检查页面内容中的错误
if (pageOk) {
const pageErrors = await checkPageForErrors(pageUrl)
if (pageErrors.length > 0) {
return {
status: 'ERROR',
phase: 'build',
message: 'Detected error in page content',
url: pageUrl,
errors: pageErrors,
logs
}
}
}
// 错误稳定:最近 stableCheckMs 内没有新的错误
const stable = (now - lastErrorTime) > CONFIG.stableCheckMs
if (!pageOk) {
// 页面不可访问,继续轮询
await sleep(CONFIG.pollIntervalMs)
continue
}
if (errors.length > 0) {
return {
status: 'ERROR',
phase: 'build',
message: 'Detected Vite build/runtime error',
url: pageUrl,
errors,
logs
}
}
if (pageOk && stable) {
return {
status: 'READY',
phase: 'done',
message: 'Page ready and stable',
url: pageUrl,
errors: [],
logs
}
}
await sleep(CONFIG.pollIntervalMs)
}
return {
status: 'TIMEOUT',
phase: 'server',
message: 'Timeout waiting for page/stable state',
url: pageUrl,
errors,
logs
}
}
/**
* 为结果添加服务器首页信息
*/
function addUrls(result, serverInfo) {
if (!serverInfo) {
return {
...result,
homeUrl: null,
targetUrl: null,
targetPath: null
}
}
const entryHtmlPath = getEntryHtmlPath(CONFIG.pagePath)
return {
...result,
homeUrl: getHomeUrl(serverInfo),
targetUrl: getTargetUrl(serverInfo, CONFIG.pagePath),
targetPath: getTargetUrl(serverInfo, entryHtmlPath)
}
}
function readPackageJson() {
const pkgPath = path.resolve(APP_ROOT, 'package.json')
try {
return JSON.parse(fs.readFileSync(pkgPath, 'utf8'))
} catch (err) {
logs.push(`Failed to read package.json: ${err.message}`)
return null
}
}
function getScriptCommand(pkgJson, scriptName) {
if (!pkgJson || !pkgJson.scripts) return null
return pkgJson.scripts[scriptName] || null
}
function hasEslintConfig(pkgJson) {
if (pkgJson && pkgJson.eslintConfig) return true
const configFiles = [
'.eslintrc',
'.eslintrc.js',
'.eslintrc.cjs',
'.eslintrc.json',
'.eslintrc.yaml',
'.eslintrc.yml',
'eslint.config.js',
'eslint.config.cjs',
'eslint.config.mjs'
]
return configFiles.some((file) => fs.existsSync(path.resolve(APP_ROOT, file)))
}
function hasTsConfig() {
return fs.existsSync(path.resolve(APP_ROOT, 'tsconfig.json'))
}
function toCheckItem(name, result) {
if (!result) return null
return {
name,
status: result.status,
message: result.message,
errors: result.errors || []
}
}
function buildChecksSummary({ lintResult, typeCheckResult, buildResult }) {
return [
toCheckItem('lint', lintResult),
toCheckItem('typecheck', typeCheckResult),
toCheckItem('build', buildResult)
].filter(Boolean)
}
async function runCommandCheck({ label, command, args = [], env = {}, logTag }) {
logs.push(`${label} check started`)
return new Promise((resolve) => {
const checkErrors = []
const checkLogs = []
const resolvedCommand = command === 'npm'
? getPreferredNpmCommand()
: command === 'npx'
? getPreferredNpxCommand()
: command
const proc = spawn(resolvedCommand, args, {
cwd: APP_ROOT,
env: { ...process.env, ...env },
stdio: ['ignore', 'pipe', 'pipe']
})
const appendLog = (line, isError = false) => {
if (!line) return
checkLogs.push(line)
logs.push(`[${logTag}] ${line}`)
if (isError && !/deprecated|experimental/i.test(line)) {
checkErrors.push(line)
}
}
proc.stdout.on('data', (data) => {
appendLog(decodeOutput(data).trim(), false)
})
proc.stderr.on('data', (data) => {
appendLog(decodeOutput(data).trim(), true)
})
proc.on('close', (code) => {
if (code === 0 && checkErrors.length === 0) {
resolve({
status: 'SUCCESS',
message: `${label} completed successfully`,
errors: [],
logs: checkLogs
})
return
}
resolve({
status: 'FAILED',
message: `${label} failed (exit code: ${code})`,
errors: checkErrors.length > 0 ? checkErrors : [`${label} exited with code ${code}`],
logs: checkLogs
})
})
proc.on('error', (err) => {
logs.push(`${label} process error: ${err.message}`)
resolve({
status: 'FAILED',
message: `${label} process error: ${err.message}`,
errors: [err.message],
logs: checkLogs
})
})
})
}
async function runLintCheck() {
const pkgJson = readPackageJson()
const lintScript = getScriptCommand(pkgJson, 'lint')
if (lintScript) {
return runCommandCheck({
label: 'Lint',
command: 'npm',
args: ['run', 'lint'],
logTag: 'LINT'
})
}
if (!hasEslintConfig(pkgJson)) {
return {
status: 'SKIPPED',
message: 'Lint skipped: no eslint config or lint script found',
errors: [],
logs: []
}
}
return runCommandCheck({
label: 'Lint',
command: 'npx',
args: ['eslint', '.'],
logTag: 'LINT'
})
}
async function runTypeCheck() {
const pkgJson = readPackageJson()
const typecheckScript = getScriptCommand(pkgJson, 'typecheck')
if (typecheckScript) {
return runCommandCheck({
label: 'Typecheck',
command: 'npm',
args: ['run', 'typecheck'],
logTag: 'TYPECHECK'
})
}
if (!hasTsConfig()) {
return {
status: 'SKIPPED',
message: 'Typecheck skipped: no tsconfig.json or typecheck script found',
errors: [],
logs: []
}
}
return runCommandCheck({
label: 'Typecheck',
command: 'npx',
args: ['tsc', '--noEmit'],
logTag: 'TYPECHECK'
})
}
/**
* 扫描并更新 .axhub/make/entries.json
* 确保新创建的目录被包含在入口列表中
*/
async function scanEntries() {
logs.push('Scanning entries...')
return new Promise((resolve) => {
const scanProcess = spawn(process.execPath, ['scripts/scan-entries.js'], {
cwd: APP_ROOT,
stdio: ['ignore', 'pipe', 'pipe']
})
scanProcess.stdout.on('data', (data) => {
const text = decodeOutput(data).trim()
if (text) logs.push(`[SCAN] ${text}`)
})
scanProcess.stderr.on('data', (data) => {
const text = decodeOutput(data).trim()
if (text) logs.push(`[SCAN ERROR] ${text}`)
})
scanProcess.on('close', (code) => {
if (code === 0) {
logs.push('Entry scanning completed')
resolve({ success: true })
} else {
logs.push(`Entry scanning failed with exit code ${code}`)
resolve({ success: false })
}
})
scanProcess.on('error', (err) => {
logs.push(`Entry scanning error: ${err.message}`)
resolve({ success: false })
})
})
}
/**
* 执行独立构建校验
* 针对指定的入口 key 执行单独构建,不是全量构建
*/
async function runBuildCheck(entryKey) {
const originalEntryKey = String(entryKey ?? '').trim()
logs.push(`Starting build check for entry: ${originalEntryKey || '(auto)'}`)
// 先扫描入口,确保 .axhub/make/entries.json 是最新的
const scanResult = await scanEntries()
if (!scanResult.success) {
return {
status: 'FAILED',
message: 'Failed to scan entries before build',
errors: ['Entry scanning failed'],
logs: []
}
}
const resolvedEntryKey = originalEntryKey || resolveDefaultEntryKey()
if (!resolvedEntryKey) {
logs.push('Build check skipped: no entry key resolved')
return {
status: 'SKIPPED',
message: 'Build check skipped: no entry key resolved',
errors: [],
logs: []
}
}
return new Promise((resolve) => {
const buildErrors = []
const buildLogs = []
const npxCommand = getPreferredNpxCommand()
// 使用 ENTRY_KEY 环境变量触发单独构建
const buildProcess = spawn(npxCommand, ['vite', 'build'], {
cwd: APP_ROOT,
env: { ...process.env, ENTRY_KEY: resolvedEntryKey },
stdio: ['ignore', 'pipe', 'pipe']
})
buildProcess.stdout.on('data', (data) => {
const text = decodeOutput(data).trim()
if (text) {
buildLogs.push(text)
logs.push(`[BUILD] ${text}`)
}
})
buildProcess.stderr.on('data', (data) => {
const text = decodeOutput(data).trim()
if (text) {
buildLogs.push(text)
logs.push(`[BUILD ERROR] ${text}`)
// 捕获构建错误
if (/error|failed/i.test(text) && !/deprecated|experimental/i.test(text)) {
buildErrors.push(text)
}
}
})
buildProcess.on('close', (code) => {
if (code === 0 && buildErrors.length === 0) {
logs.push(`Build check completed successfully for ${resolvedEntryKey}`)
resolve({
status: 'SUCCESS',
message: `Build completed successfully for ${resolvedEntryKey}`,
errors: [],
logs: buildLogs
})
} else {
logs.push(`Build check failed for ${resolvedEntryKey} with exit code ${code}`)
resolve({
status: 'FAILED',
message: `Build failed for ${resolvedEntryKey} (exit code: ${code})`,
errors: buildErrors.length > 0 ? buildErrors : [`Build process exited with code ${code}`],
logs: buildLogs
})
}
})
buildProcess.on('error', (err) => {
logs.push(`Build process error: ${err.message}`)
resolve({
status: 'FAILED',
message: `Build process error: ${err.message}`,
errors: [err.message],
logs: buildLogs
})
})
})
}
function resolveDefaultEntryKey() {
try {
const entriesPath = path.resolve(APP_ROOT, '.axhub/make/entries.json')
if (!fs.existsSync(entriesPath)) return null
const raw = JSON.parse(fs.readFileSync(entriesPath, 'utf8'))
const jsEntries = raw && typeof raw === 'object' ? (raw.js || {}) : {}
const keys = Object.keys(jsEntries || {}).filter(Boolean).sort((a, b) => a.localeCompare(b))
if (keys.length === 0) return null
const pickFromPrefix = (prefix) => keys.find((k) => k.startsWith(prefix))
return (
pickFromPrefix('prototypes/') ||
pickFromPrefix('components/') ||
pickFromPrefix('themes/') ||
keys[0] ||
null
)
} catch (err) {
logs.push(`Failed to resolve default entry key: ${err.message}`)
return null
}
}
/**
* 从页面路径推断入口 key
* 例如:/components/button -> components/button
*/
function getEntryKeyFromPath(pagePath) {
// 移除开头的斜杠
return pagePath.replace(/^\//, '')
}
/* ================= 主流程 ================= */
async function main() {
try {
// 获取服务器信息
const serverInfo = getServerInfo()
// 如果没有端口信息,等待服务器启动
if (!serverInfo) {
logs.push('Waiting for server to start...')
// 启动服务器并等待
const viteProcess = startOrAttachVite()
// 等待 .axhub/make/.dev-server-info.json 文件生成
const maxWait = 10000 // 10秒
const startTime = Date.now()
let newServerInfo = null
while (Date.now() - startTime < maxWait) {
await new Promise(resolve => setTimeout(resolve, 500))
newServerInfo = getServerInfo()
if (newServerInfo) break
}
if (!newServerInfo) {
return jsonExit(addUrls({
status: 'ERROR',
phase: 'server',
message: 'Server failed to start - no port information available',
url: CONFIG.pagePath,
errors: ['Server did not write port information within timeout'],
logs
}, null), 1)
}
// 使用新获取的服务器信息
const accessibleHost = getAccessibleHost(newServerInfo)
const pageUrl = `http://${accessibleHost}:${newServerInfo.port}${CONFIG.pagePath}`
logs.push(`Target URL: ${pageUrl}`)
logs.push(`Server info: port=${newServerInfo.port}, host=${newServerInfo.host}`)
// 继续后续流程...
await continueWithServerInfo(newServerInfo, pageUrl, viteProcess)
} else {
const accessibleHost = getAccessibleHost(serverInfo)
const pageUrl = `http://${accessibleHost}:${serverInfo.port}${CONFIG.pagePath}`
logs.push(`Target URL: ${pageUrl}`)
logs.push(`Server info: port=${serverInfo.port}, host=${serverInfo.host}`)
// 继续后续流程...
await continueWithServerInfo(serverInfo, pageUrl, null)
}
} catch (err) {
const serverInfo = getServerInfo()
jsonExit(addUrls({
status: 'ERROR',
phase: 'server',
message: err.message,
url: CONFIG.pagePath,
errors: [String(err)],
logs
}, serverInfo), 1)
}
}
async function continueWithServerInfo(serverInfo, pageUrl, viteProcess) {
try {
// 步骤 1: 执行 lint 检查
const lintResult = await runLintCheck()
if (lintResult.status === 'FAILED') {
return jsonExit(addUrls({
status: 'ERROR',
phase: 'lint',
message: lintResult.message,
url: pageUrl,
errors: lintResult.errors,
logs,
lintCheck: lintResult,
checks: buildChecksSummary({ lintResult })
}, serverInfo), 1)
}
// 步骤 2: 执行 typecheck 检查
const typeCheckResult = await runTypeCheck()
if (typeCheckResult.status === 'FAILED') {
return jsonExit(addUrls({
status: 'ERROR',
phase: 'typecheck',
message: typeCheckResult.message,
url: pageUrl,
errors: typeCheckResult.errors,
logs,
lintCheck: lintResult,
typeCheck: typeCheckResult,
checks: buildChecksSummary({ lintResult, typeCheckResult })
}, serverInfo), 1)
}
// 步骤 3: 执行构建校验(除非指定 --skip-build
let buildResult = null
if (!CONFIG.skipBuild) {
const entryKey = getEntryKeyFromPath(CONFIG.pagePath)
logs.push(`Build check enabled for entry: ${entryKey}`)
buildResult = await runBuildCheck(entryKey)
// 如果构建失败,直接返回错误
if (buildResult.status === 'FAILED') {
return jsonExit(addUrls({
status: 'ERROR',
phase: 'build',
message: buildResult.message,
url: pageUrl,
errors: buildResult.errors,
logs,
buildCheck: buildResult,
lintCheck: lintResult,
typeCheck: typeCheckResult,
checks: buildChecksSummary({ lintResult, typeCheckResult, buildResult })
}, serverInfo), 1)
}
} else {
logs.push('Build check skipped (--skip-build flag)')
buildResult = {
status: 'SKIPPED',
message: 'Build check skipped (--skip-build flag)',
errors: [],
logs: []
}
}
// 步骤 4: 开发服务器校验
const accessibleHost = getAccessibleHost(serverInfo)
// 检查服务器是否已经在运行
const serverAlreadyRunning = await isServerAlive(`http://${accessibleHost}:${serverInfo.port}`)
let viteChild = viteProcess
if (!serverAlreadyRunning && !viteChild) {
logs.push('Server not running, starting Vite...')
viteChild = startOrAttachVite()
} else {
logs.push('Server already running, skipping start')
}
// 等待页面可访问
const pageReachable = await waitForPage(pageUrl)
if (!pageReachable) {
if (viteChild) viteChild.kill()
return jsonExit(addUrls({
status: 'TIMEOUT',
phase: 'page',
message: 'Page never became reachable',
url: pageUrl,
errors,
logs,
buildCheck: buildResult,
lintCheck: lintResult,
typeCheck: typeCheckResult,
checks: buildChecksSummary({ lintResult, typeCheckResult, buildResult })
}, serverInfo), 1)
}
// 等待稳定状态
const result = await waitForStable(pageUrl)
// 清理进程
if (viteChild) viteChild.kill()
// 添加构建结果到最终输出
const finalResult = {
...result,
buildCheck: buildResult,
lintCheck: lintResult,
typeCheck: typeCheckResult,
checks: buildChecksSummary({ lintResult, typeCheckResult, buildResult })
}
jsonExit(addUrls(finalResult, serverInfo), result.status === 'READY' ? 0 : 1)
} catch (err) {
jsonExit(addUrls({
status: 'ERROR',
phase: 'server',
message: err.message,
url: CONFIG.pagePath,
errors: [String(err)],
logs
}, serverInfo), 1)
}
}
main()

View File

@@ -0,0 +1,39 @@
import fs from 'fs';
import path from 'path';
const appRoot = process.cwd();
const srcRoot = path.join(appRoot, 'src');
const previewGroups = ['components', 'prototypes', 'themes'];
const legacyHtmlFiles = [];
function walk(dir) {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
walk(fullPath);
continue;
}
if (entry.isFile() && entry.name === 'index.html') {
legacyHtmlFiles.push(path.relative(appRoot, fullPath));
}
}
}
for (const group of previewGroups) {
const groupRoot = path.join(srcRoot, group);
if (fs.existsSync(groupRoot)) {
walk(groupRoot);
}
}
if (legacyHtmlFiles.length > 0) {
console.error('Found legacy preview HTML files under src/. Dev preview must use the virtual host pipeline only:');
for (const file of legacyHtmlFiles) {
console.error(`- ${file}`);
}
process.exit(1);
}
console.log('No legacy src preview HTML files found.');

View File

@@ -0,0 +1,826 @@
#!/usr/bin/env node
/**
* =====================================================
* Chrome 扩展导出转换器
*
* 专门处理通过 Chrome 扩展本项目导出的 HTML 文件
*
* 功能:
* 1. 转换 index.html 为 React 组件
* 2. 智能处理字体CDN 保留链接,本地文件复制
* 3. 复制静态资源(图片、字体)
* 4. 保留完整的 style.css 样式
* =====================================================
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { assertValidGeneratedTsx } from './utils/generatedTsxValidator.mjs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const CONFIG = {
projectRoot: path.resolve(__dirname, '..'),
pagesDir: path.resolve(__dirname, '../src/prototypes')
};
const JSX_ATTRIBUTE_REPLACEMENTS = [
['class', 'className'],
['for', 'htmlFor'],
['tabindex', 'tabIndex'],
['readonly', 'readOnly'],
['maxlength', 'maxLength'],
['minlength', 'minLength'],
['colspan', 'colSpan'],
['rowspan', 'rowSpan'],
['viewbox', 'viewBox'],
['preserveaspectratio', 'preserveAspectRatio'],
['clip-path', 'clipPath'],
['fill-rule', 'fillRule'],
['clip-rule', 'clipRule'],
['stroke-width', 'strokeWidth'],
['stroke-dasharray', 'strokeDasharray'],
['stroke-dashoffset', 'strokeDashoffset'],
['stroke-linecap', 'strokeLinecap'],
['stroke-linejoin', 'strokeLinejoin'],
['stroke-miterlimit', 'strokeMiterlimit'],
['stroke-opacity', 'strokeOpacity'],
['fill-opacity', 'fillOpacity'],
['stop-color', 'stopColor'],
['stop-opacity', 'stopOpacity'],
['xlink:href', 'xlinkHref'],
['xmlns:xlink', 'xmlnsXlink'],
];
function log(message, type = 'info') {
const prefix = { info: '✓', warn: '⚠', error: '✗', progress: '⏳' }[type] || '';
console.log(`${prefix} ${message}`);
}
function ensureDir(dirPath) {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
}
function normalizeRelativeDir(value) {
return String(value ?? '')
.replace(/\\/g, '/')
.split('/')
.filter(Boolean)
.join('/');
}
function isSafeRelativeDir(value) {
if (!value) return false;
if (value.startsWith('/') || value.startsWith('~')) return false;
const segments = value.split('/');
if (segments.length === 0) return false;
return segments.every((segment) => {
if (!segment || segment === '.' || segment === '..') return false;
return !/[\\/]/.test(segment);
});
}
/**
* 判断是否为 CDN 链接
*/
function isCDNUrl(url) {
return url.startsWith('http://') || url.startsWith('https://') || url.startsWith('//');
}
/**
* 递归复制目录
*/
function copyDirectory(src, dest) {
if (!fs.existsSync(src)) return 0;
ensureDir(dest);
const entries = fs.readdirSync(src, { withFileTypes: true });
let count = 0;
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
count += copyDirectory(srcPath, destPath);
} else {
fs.copyFileSync(srcPath, destPath);
count++;
}
}
return count;
}
/**
* 提取 head 内容(仅处理外部资源和字体)
*/
function extractHeadContent(html) {
const headMatch = html.match(/<head[^>]*>([\s\S]*?)<\/head>/i);
if (!headMatch) return { scripts: [], links: [] };
const headContent = headMatch[1];
const scripts = [];
const links = [];
// 提取 script 标签(排除 Tailwind CDN
const scriptRegex = /<script([^>]*)>([\s\S]*?)<\/script>/gi;
let match;
while ((match = scriptRegex.exec(headContent)) !== null) {
const attrs = match[1];
const content = match[2].trim();
const srcMatch = attrs.match(/src=["']([^"']+)["']/);
if (srcMatch) {
const src = srcMatch[1].replace(/&amp;/g, '&');
// 跳过 Tailwind CDN
if (src.includes('tailwindcss.com')) continue;
scripts.push({
src,
id: attrs.match(/id=["']([^"']+)["']/)?.[1]
});
} else if (content) {
const id = attrs.match(/id=["']([^"']+)["']/)?.[1];
scripts.push({ id, content });
}
}
// 提取 link 标签
const linkRegex = /<link[^>]*>/gi;
while ((match = linkRegex.exec(headContent)) !== null) {
const tag = match[0];
const href = tag.match(/href=["']([^"']+)["']/)?.[1];
if (href) {
links.push({
href: href.replace(/&amp;/g, '&'),
rel: tag.match(/rel=["']([^"']+)["']/)?.[1] || 'stylesheet',
crossorigin: tag.includes('crossorigin')
});
}
}
return { scripts, links };
}
/**
* 转义文本节点中的花括号
* 只处理标签之间的文本内容,不处理属性值
*/
function escapeTextBraces(html) {
const parts = [];
let lastIndex = 0;
const tagRegex = /<[^>]+>/g;
let match;
while ((match = tagRegex.exec(html)) !== null) {
// 提取标签之前的文本
const textBefore = html.substring(lastIndex, match.index);
if (textBefore) {
// 转义文本中的花括号 - 使用占位符避免重复替换
const escaped = textBefore
.replace(/\{/g, "__LBRACE__")
.replace(/\}/g, "__RBRACE__");
parts.push(escaped);
}
// 添加标签本身(不转义)
parts.push(match[0]);
lastIndex = tagRegex.lastIndex;
}
// 添加最后一段文本
const textAfter = html.substring(lastIndex);
if (textAfter) {
const escaped = textAfter
.replace(/\{/g, "__LBRACE__")
.replace(/\}/g, "__RBRACE__");
parts.push(escaped);
}
return parts.join('')
.replace(/__LBRACE__/g, "{'{'}")
.replace(/__RBRACE__/g, "{'}'}")
}
function convertCommonAttributesToJSX(content) {
let nextContent = content;
JSX_ATTRIBUTE_REPLACEMENTS.forEach(([from, to]) => {
nextContent = nextContent.replace(new RegExp(`(\\s)${from}=`, 'gi'), `$1${to}=`);
});
return nextContent;
}
function createCommentPlaceholders(content) {
const comments = [];
const withPlaceholders = content.replace(/<!--([\s\S]*?)-->/g, (_, commentBody) => {
const placeholder = `__HTML_COMMENT_${comments.length}__`;
comments.push(`{/* ${commentBody} */}`);
return placeholder;
});
return { withPlaceholders, comments };
}
function restoreCommentPlaceholders(content, comments) {
return comments.reduce(
(currentContent, comment, index) => currentContent.replaceAll(`__HTML_COMMENT_${index}__`, comment),
content,
);
}
function convertHtmlToJSX(content) {
let nextContent = convertCommonAttributesToJSX(content)
.replace(/(<pre[^>]*>)([\s\S]*?)(<\/pre>)/gi, (_, openTag, preContent) => {
const escapedContent = preContent
.replace(/\\/g, '\\\\')
.replace(/`/g, '\\`')
.replace(/\$/g, '\\$')
.replace(/\{/g, '\\{');
return `${openTag.slice(0, -1)} dangerouslySetInnerHTML={{ __html: \`${escapedContent}\` }} />`;
})
.replace(/style='([^']*)'/gi, (_, styleStr) => convertStyleToJSX(styleStr))
.replace(/style="([^"]*)"/gi, (_, styleStr) => convertStyleToJSX(styleStr))
.replace(/<\/(br|hr|img|input|meta|link)>/gi, '')
.replace(/<(br|hr|img|input|meta|link)([^>]*)>/gi, '<$1$2 />')
.replace(/<body\b([^>]*)>/gi, '<div data-chrome-export-body="true"$1>')
.replace(/<\/body>/gi, '</div>')
.replace(/&lt;\//g, '__LTSLASH__')
.replace(/&lt;/g, '__LT__')
.replace(/&gt;/g, '__GT__')
.replace(/&amp;/g, '__AMP__');
const { withPlaceholders, comments } = createCommentPlaceholders(nextContent);
nextContent = escapeTextBraces(withPlaceholders);
nextContent = restoreCommentPlaceholders(nextContent, comments);
return nextContent
.replace(/__LTSLASH__/g, "{'</'}")
.replace(/__LT__/g, "{'<'}")
.replace(/__GT__/g, "{'>'}")
.replace(/__AMP__/g, '&');
}
/**
* 提取并转换 body 内容
*/
function extractBodyContent(html) {
const bodyMatch = html.match(/(<body[^>]*>)([\s\S]*?)(<\/body>)/i);
if (!bodyMatch) return '';
const [, openTag, innerContent] = bodyMatch;
// 移除 <root> 标签Chrome 扩展导出特有的包装标签)
let cleanedContent = innerContent.trim()
.replace(/^\s*<root>\s*/i, '')
.replace(/\s*<\/root>\s*$/i, '');
const convertedOpenTag = convertCommonAttributesToJSX(openTag)
.replace(/^<body\b/i, '<div data-chrome-export-root="true"');
const content = convertHtmlToJSX(cleanedContent);
return `${convertedOpenTag}\n${content}\n </div>`;
}
function convertStyleToJSX(styleStr) {
if (!styleStr.trim()) return 'style={{}}';
// 先解码 HTML 实体
const decodedStr = styleStr
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&');
const styles = [];
let currentProp = '';
let inUrl = false;
for (let i = 0; i < decodedStr.length; i++) {
const char = decodedStr[i];
if (char === '(' && decodedStr.substring(i - 3, i) === 'url') inUrl = true;
else if (char === ')' && inUrl) inUrl = false;
if (char === ';' && !inUrl) {
if (currentProp.trim()) styles.push(currentProp.trim());
currentProp = '';
} else {
currentProp += char;
}
}
if (currentProp.trim()) styles.push(currentProp.trim());
const jsxStyles = styles
.filter(s => s.includes(':'))
.map(s => {
const colonIndex = s.indexOf(':');
const key = s.substring(0, colonIndex).trim();
const value = s.substring(colonIndex + 1).trim();
if (!key || !value) return '';
const camelKey = key.startsWith('-')
? JSON.stringify(key)
: key.replace(/-([a-z])/g, (g) => g[1].toUpperCase());
let jsxValue;
if (value.startsWith('url(') || value.includes('var(')) {
jsxValue = `'${value.replace(/'/g, "\\'")}'`;
} else if (/^-?\d+(\.\d+)?$/.test(value)) {
jsxValue = value;
} else {
// 转义单引号和反斜杠
const escapedValue = value.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
jsxValue = `'${escapedValue}'`;
}
return `${camelKey}: ${jsxValue}`;
})
.filter(Boolean)
.join(', ');
return `style={{ ${jsxStyles} }}`;
}
/**
* 生成组件代码
*/
function normalizeDisplayName(displayName) {
const text = String(displayName ?? '').trim();
const singleLine = text.replace(/\r?\n/g, ' ');
const safeText = singleLine.replace(/\*\//g, '* /');
return safeText.slice(0, 200);
}
function generateComponent(pageSlug, displayName, bodyContent, headContent) {
const componentName = pageSlug
.split(/[-_\s]+/)
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join('');
const safeDisplayName = normalizeDisplayName(displayName || pageSlug);
let cleanedContent = bodyContent.trim();
if (cleanedContent.startsWith('{/*')) {
const firstTagIndex = cleanedContent.indexOf('<');
if (firstTagIndex > 0) {
cleanedContent = cleanedContent.substring(firstTagIndex);
}
}
const needsWrapper = !isWrappedInSingleElement(cleanedContent);
const finalContent = needsWrapper ? `<>\n${cleanedContent}\n </>` : cleanedContent;
// 生成注入代码
let injectionCode = '';
if (headContent.links.length > 0 || headContent.scripts.length > 0) {
const hasExternalScripts = headContent.scripts.some(s => s.src);
injectionCode = `
// 动态注入外部资源
React.useEffect(function () {
const injected: (HTMLElement)[] = [];
`;
if (headContent.links.length > 0) {
injectionCode += `
// 注入 links
${JSON.stringify(headContent.links)}.forEach(function (linkInfo: any) {
const existing = document.querySelector(\`link[href="\${linkInfo.href}"]\`);
if (!existing) {
const link = document.createElement('link');
link.rel = linkInfo.rel;
link.href = linkInfo.href;
if (linkInfo.crossorigin) link.crossOrigin = 'anonymous';
document.head.appendChild(link);
injected.push(link);
}
});
`;
}
if (hasExternalScripts) {
injectionCode += `
// 注入外部脚本
${JSON.stringify(headContent.scripts.filter(s => s.src))}.forEach(function (scriptInfo: any) {
const existing = document.querySelector(\`script[src="\${scriptInfo.src}"]\`);
if (!existing) {
const script = document.createElement('script');
if (scriptInfo.id) script.id = scriptInfo.id;
script.src = scriptInfo.src;
document.head.appendChild(script);
injected.push(script);
}
});
`;
}
injectionCode += `
return function () {
injected.forEach(function (el) {
if (el.parentNode) el.parentNode.removeChild(el);
});
};
}, []);
`;
}
return `/**
* @name ${safeDisplayName}
*
* 参考资料:
* - /rules/development-guide.md
* - /skills/default-resource-recommendations/SKILL.md
*/
import './style.css';
import React, { forwardRef, useImperativeHandle } from 'react';
import type { AxureProps, AxureHandle } from '../../common/axure-types';
class ErrorBoundary extends React.Component<
{ children: React.ReactNode },
{ hasError: boolean; error: Error | null }
> {
constructor(props: any) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error) {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('[${pageSlug}] 组件渲染错误:', error);
console.error('[${pageSlug}] 错误详情:', errorInfo);
console.error('[${pageSlug}] 错误堆栈:', error.stack);
}
render() {
if (this.state.hasError) {
return (
<div style={{ padding: '20px', color: 'red', border: '2px solid red', margin: '20px' }}>
<h2>组件渲染失败: ${safeDisplayName}</h2>
<pre style={{ whiteSpace: 'pre-wrap', fontSize: '12px' }}>
{this.state.error?.toString()}
{this.state.error?.stack}
</pre>
</div>
);
}
return this.props.children;
}
}
const Component = forwardRef(function ${componentName}(
innerProps: AxureProps,
ref: React.ForwardedRef<AxureHandle>,
) {
console.log('[${pageSlug}] 组件开始渲染');
useImperativeHandle(ref, function () {
return {
getVar: function () { return undefined; },
fireAction: function () {},
eventList: [],
actionList: [],
varList: [],
configList: [],
dataList: []
};
}, []);
${injectionCode}
console.log('[${pageSlug}] 准备返回 JSX');
try {
return (
${finalContent.split('\n').map(line => ' ' + line).join('\n')}
);
} catch (error) {
console.error('[${pageSlug}] JSX 渲染错误:', error);
throw error;
}
});
const WrappedComponent = forwardRef(function WrappedComponent(
props: AxureProps,
ref: React.ForwardedRef<AxureHandle>,
) {
return (
<ErrorBoundary>
<Component {...props} ref={ref} />
</ErrorBoundary>
);
});
export default WrappedComponent;
`;
}
function isWrappedInSingleElement(content) {
const trimmed = content.trim();
if (!trimmed.startsWith('<')) return false;
if (trimmed.startsWith('<body')) return trimmed.endsWith('</body>');
const firstTagMatch = trimmed.match(/^<([a-zA-Z][a-zA-Z0-9]*)/);
if (!firstTagMatch) return false;
const tagName = firstTagMatch[1];
const closingTag = `</${tagName}>`;
if (!trimmed.endsWith(closingTag)) return false;
const openCount = (trimmed.match(new RegExp(`<${tagName}[\\s>]`, 'g')) || []).length;
const closeCount = (trimmed.match(new RegExp(`</${tagName}>`, 'g')) || []).length;
return openCount === closeCount && openCount === 1;
}
/**
* 生成 CSS 文件(仅处理外部 style.css
*/
function generateStyleCSS(fonts, sourcePath) {
let css = '@import "tailwindcss";\n';
// 处理字体
if (fonts && fonts.length > 0) {
css += '\n/* 字体定义 */\n';
const cdnFonts = fonts.filter(f => f.isCDN);
const localFonts = fonts.filter(f => !f.isCDN);
if (cdnFonts.length > 0) {
css += '\n/* CDN 字体(保留原始链接) */\n';
cdnFonts.forEach(font => {
css += font.rule + '\n\n';
});
}
if (localFonts.length > 0) {
css += '\n/* 本地字体(已复制到 assets 目录) */\n';
localFonts.forEach(font => {
// 将字体路径改为相对于 style.css 的路径
const modifiedRule = font.rule.replace(
/url\(['"]?([^'")\s]+)['"]?\)/g,
(match, url) => `url('./${url}')`
);
css += modifiedRule + '\n\n';
});
}
}
// 读取外部 CSS 文件的完整内容(排除字体定义)
const externalCSSPath = path.join(sourcePath, 'style.css');
if (fs.existsSync(externalCSSPath)) {
const externalCSS = fs.readFileSync(externalCSSPath, 'utf8');
// 移除 @font-face 规则(已单独处理)
const withoutFontFace = externalCSS.replace(/@font-face\s*\{[^}]+\}/g, '').trim();
if (withoutFontFace) {
css += '\n/* 样式类定义(来自 style.css*/\n';
css += withoutFontFace + '\n';
}
}
return css;
}
/**
* 从外部 CSS 文件提取字体
*/
function extractFontsFromCSS(cssPath) {
if (!fs.existsSync(cssPath)) return [];
const css = fs.readFileSync(cssPath, 'utf8');
const fonts = [];
const fontFaceRegex = /@font-face\s*\{([^}]+)\}/g;
let match;
while ((match = fontFaceRegex.exec(css)) !== null) {
const fontRule = match[1];
const srcMatch = fontRule.match(/src:\s*url\(['"]?([^'")\s]+)['"]?\)/);
const familyMatch = fontRule.match(/font-family:\s*['"]([^'"]+)['"]/);
if (srcMatch && familyMatch) {
const fontSrc = srcMatch[1];
const fontFamily = familyMatch[1];
fonts.push({
family: fontFamily,
src: fontSrc,
isCDN: isCDNUrl(fontSrc),
rule: match[0]
});
}
}
return fonts;
}
/**
* 转换单个页面
*/
function convertPage(sourcePath, outputDir, pageSlug, displayName) {
log(`正在转换页面: ${pageSlug}`, 'progress');
// Chrome 扩展导出固定使用 index.html
const htmlPath = path.join(sourcePath, 'index.html');
if (!fs.existsSync(htmlPath)) {
throw new Error(`找不到 index.html 文件: ${htmlPath}`);
}
const html = fs.readFileSync(htmlPath, 'utf8');
const headContent = extractHeadContent(html);
const bodyContent = extractBodyContent(html);
// 从外部 CSS 文件提取字体
const externalCSSPath = path.join(sourcePath, 'style.css');
let fonts = [];
if (fs.existsSync(externalCSSPath)) {
fonts = extractFontsFromCSS(externalCSSPath);
if (fonts.length > 0) {
log(` ✓ 从 style.css 提取了 ${fonts.length} 个字体定义`, 'info');
}
}
ensureDir(outputDir);
// 生成组件和样式
const componentCode = generateComponent(pageSlug, displayName, bodyContent, headContent);
const styleCSS = generateStyleCSS(fonts, sourcePath);
const outputTsxPath = path.join(outputDir, 'index.tsx');
assertValidGeneratedTsx(componentCode, outputTsxPath);
fs.writeFileSync(outputTsxPath, componentCode);
fs.writeFileSync(path.join(outputDir, 'style.css'), styleCSS);
// 复制静态资源
const assetsPath = path.join(sourcePath, 'assets');
if (fs.existsSync(assetsPath)) {
const outputAssetsPath = path.join(outputDir, 'assets');
// 复制图片
const imagesPath = path.join(assetsPath, 'images');
if (fs.existsSync(imagesPath)) {
const imageCount = copyDirectory(imagesPath, path.join(outputAssetsPath, 'images'));
if (imageCount > 0) {
log(` ✓ 复制了 ${imageCount} 个图片文件`, 'info');
}
}
// 复制本地字体
const localFonts = fonts.filter(f => !f.isCDN);
if (localFonts.length > 0) {
const fontsPath = path.join(assetsPath, 'fonts');
if (fs.existsSync(fontsPath)) {
const fontCount = copyDirectory(fontsPath, path.join(outputAssetsPath, 'fonts'));
log(` ✓ 复制了 ${fontCount} 个字体文件`, 'info');
}
}
// 统计 CDN 字体
const cdnFonts = fonts.filter(f => f.isCDN);
if (cdnFonts.length > 0) {
log(` ✓ 保留了 ${cdnFonts.length} 个 CDN 字体链接`, 'info');
}
}
// 复制参考文件(如果存在)
const filesToCopy = ['screenshot.png', 'content.md', 'theme.json'];
filesToCopy.forEach(filename => {
const srcFile = path.join(sourcePath, filename);
if (fs.existsSync(srcFile)) {
const destFile = path.join(outputDir, filename);
fs.copyFileSync(srcFile, destFile);
log(` ✓ 复制了 ${filename}`, 'info');
}
});
log(`页面转换完成: ${pageSlug}`, 'info');
}
/**
* 检测项目类型(仅支持 Chrome 扩展导出)
*/
function detectProjectType(sourcePath) {
const items = fs.readdirSync(sourcePath);
// 检查是否为 Chrome 扩展导出格式(有 index.html
if (items.includes('index.html')) {
return { type: 'chrome-export', prototypes: [{ name: 'index', path: sourcePath }] };
}
throw new Error('未找到 index.html 文件,请确认这是 Chrome 扩展导出的项目');
}
/**
* 主函数
*/
async function main() {
const args = process.argv.slice(2);
if (args.length === 0 || args[0] === '--help') {
console.log(`
Chrome 扩展导出转换器
使用方法:
node scripts/chrome-export-converter.mjs <source-dir> [output-name] [display-name]
node scripts/chrome-export-converter.mjs <source-dir> --name <output-name> --display-name <display-name> --target-dir <relative-dir>
参数说明:
source-dir : Chrome 扩展导出的目录(包含 index.html
output-name : 输出页面名称(可选,默认使用目录名)
display-name : 页面显示名(可选,写入 index.tsx 的 @name
target-dir : 输出到 src/prototypes 下的相对目录(可选)
示例:
node scripts/chrome-export-converter.mjs ".drafts/my-export" my-page
node scripts/chrome-export-converter.mjs ".drafts/my-export" my-page "登录页"
node scripts/chrome-export-converter.mjs ".drafts/my-export" --name my-page --target-dir grouped/login-page
`);
process.exit(0);
}
const flags = {};
const positionals = [];
for (let i = 0; i < args.length; i += 1) {
const token = args[i];
if (token === '--name' || token === '--display-name' || token === '--target-dir') {
const next = args[i + 1];
if (typeof next === 'string' && next) {
flags[token] = next;
i += 1;
} else {
flags[token] = '';
}
continue;
}
positionals.push(token);
}
const sourceDirArg = positionals[0];
const outputNameRaw = flags['--name'] || positionals[1] || path.basename(sourceDirArg);
const outputName = String(outputNameRaw)
.replace(/[^a-z0-9-]/gi, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
.toLowerCase();
const displayNameRaw = flags['--display-name'] ?? positionals[2];
const displayName = (displayNameRaw !== undefined ? String(displayNameRaw).trim() : '') || outputName;
if (displayNameRaw !== undefined) {
const trimmedDisplayName = String(displayNameRaw).trim();
if (!trimmedDisplayName || trimmedDisplayName.length > 200) {
throw new Error('displayName 长度必须在 1-200 字符');
}
}
const requestedTargetDir = normalizeRelativeDir(flags['--target-dir'] || outputName);
if (!isSafeRelativeDir(requestedTargetDir)) {
throw new Error('target-dir 必须是 src/prototypes 下的安全相对路径');
}
const sourcePath = path.resolve(CONFIG.projectRoot, sourceDirArg);
const outputDir = path.resolve(CONFIG.pagesDir, requestedTargetDir);
const resolvedPagesDir = path.resolve(CONFIG.pagesDir);
if (outputDir === resolvedPagesDir || !outputDir.startsWith(`${resolvedPagesDir}${path.sep}`)) {
throw new Error('target-dir 超出 src/prototypes 目录范围');
}
if (!fs.existsSync(sourcePath)) {
log(`错误: 找不到目录 ${sourcePath}`, 'error');
process.exit(1);
}
try {
log('开始转换 Chrome 扩展导出...', 'info');
const { type, prototypes } = detectProjectType(sourcePath);
log(`项目类型: ${type}`, 'info');
convertPage(prototypes[0].path, outputDir, outputName, displayName);
log('✅ 转换完成!', 'info');
log(`📁 页面位置: ${outputDir}`, 'info');
} catch (error) {
log(`转换失败: ${error.message}`, 'error');
console.error(error);
process.exit(1);
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
main();
}
export {
convertCommonAttributesToJSX,
convertHtmlToJSX,
convertStyleToJSX,
extractBodyContent,
generateComponent,
};

View File

@@ -0,0 +1,49 @@
#!/usr/bin/env node
/**
* 清理所有 components 和 prototypes 目录下的 index.html 文件
* 因为现在使用统一的模板自动生成
*/
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const srcDir = path.resolve(__dirname, '../src');
const elementsDir = path.join(srcDir, 'components');
const pagesDir = path.join(srcDir, 'prototypes');
let removedCount = 0;
function cleanupHtmlFiles(dir) {
if (!fs.existsSync(dir)) {
return;
}
const items = fs.readdirSync(dir);
for (const item of items) {
const itemPath = path.join(dir, item);
const stat = fs.statSync(itemPath);
if (stat.isDirectory()) {
const htmlPath = path.join(itemPath, 'index.html');
if (fs.existsSync(htmlPath)) {
console.log(`删除: ${path.relative(srcDir, htmlPath)}`);
fs.unlinkSync(htmlPath);
removedCount++;
}
}
}
}
console.log('开始清理 HTML 文件...\n');
cleanupHtmlFiles(elementsDir);
cleanupHtmlFiles(pagesDir);
console.log(`\n完成!共删除 ${removedCount} 个 HTML 文件。`);
console.log('现在所有组件和页面将使用统一的模板: src/common/dev-template.html');

View File

@@ -0,0 +1,153 @@
#!/usr/bin/env node
/**
* 代码检查命令行工具
*
* 使用方法:
* node scripts/code-review.js prototypes/landing-page
* node scripts/code-review.js components/button
*
* 或者在 package.json 中添加脚本:
* "review": "node scripts/code-review.js"
*/
import http from 'http';
// 从命令行参数获取要检查的路径
const targetPath = process.argv[2];
if (!targetPath) {
console.error('❌ 错误:缺少路径参数');
console.log('\n使用方法');
console.log(' node scripts/code-review.js prototypes/landing-page');
console.log(' node scripts/code-review.js components/button');
process.exit(1);
}
// 配置
const HOST = 'localhost';
const PORT = 51720;
// 发送检查请求
function reviewCode(targetPath) {
return new Promise((resolve, reject) => {
const postData = JSON.stringify({ path: targetPath });
const options = {
hostname: HOST,
port: PORT,
path: '/api/code-review',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(postData)
}
};
const req = http.request(options, (res) => {
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
try {
const result = JSON.parse(data);
resolve(result);
} catch (error) {
reject(new Error(`解析响应失败: ${error.message}`));
}
});
});
req.on('error', (error) => {
reject(new Error(`请求失败: ${error.message}\n请确保开发服务器正在运行 (npm run dev)`));
});
req.write(postData);
req.end();
});
}
// 格式化输出
function formatResult(result) {
console.log('\n' + '='.repeat(60));
console.log(`📋 检查文件: ${result.file}`);
console.log('='.repeat(60));
if (result.issues.length === 0) {
console.log('\n✅ 太棒了!代码完全符合规范,没有发现任何问题。\n');
return;
}
// 统计问题数量
const errors = result.issues.filter(issue => issue.type === 'error');
const warnings = result.issues.filter(issue => issue.type === 'warning');
console.log(`\n发现 ${errors.length} 个错误,${warnings.length} 个警告\n`);
// 输出错误
if (errors.length > 0) {
console.log('❌ 错误 (必须修复):');
console.log('-'.repeat(60));
errors.forEach((issue, index) => {
console.log(`\n${index + 1}. [${issue.rule}]`);
console.log(` ${issue.message}`);
if (issue.suggestion) {
console.log(` 💡 建议: ${issue.suggestion}`);
}
});
console.log();
}
// 输出警告
if (warnings.length > 0) {
console.log('⚠️ 警告 (建议修复):');
console.log('-'.repeat(60));
warnings.forEach((issue, index) => {
console.log(`\n${index + 1}. [${issue.rule}]`);
console.log(` ${issue.message}`);
if (issue.suggestion) {
console.log(` 💡 建议: ${issue.suggestion}`);
}
});
console.log();
}
// 总结
console.log('='.repeat(60));
if (result.passed) {
console.log('✅ 检查通过 (仅有警告,不影响使用)');
} else {
console.log('❌ 检查未通过 (存在错误,需要修复)');
}
console.log('='.repeat(60) + '\n');
}
// 主函数
async function main() {
console.log(`\n🔍 正在检查: ${targetPath}...\n`);
try {
const result = await reviewCode(targetPath);
if (result.error) {
console.error(`❌ 检查失败: ${result.error}`);
process.exit(1);
}
formatResult(result);
// 如果有错误,退出码为 1
if (!result.passed) {
process.exit(1);
}
} catch (error) {
console.error(`\n${error.message}\n`);
process.exit(1);
}
}
main();

View File

@@ -0,0 +1,913 @@
#!/usr/bin/env node
/**
* Figma Make 项目预处理器(最小化处理模式)
*
* 只做 100% 有把握的操作:
* 1. 完整复制项目
* 2. 转换 @/ 路径别名
* 3. 转换 package@version 导入
* 4. 分析项目结构
* 5. 生成任务文档
*
* 不做任何组件逻辑改写,全部留给 AI 处理
*
* 约定输入:
* - 这里接收的 projectDir 应来自 Figma 原始导出的 ZIP 工程包解压结果
* - 不建议传入人工整理过的文件夹,以免破坏官方项目结构
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const TARGET_TYPE_TO_SRC_DIR = {
prototypes: 'src/prototypes',
components: 'src/components',
themes: 'src/themes',
};
const THEME_SPLIT_SKILL_DOCS = [
'/skills/axure-prototype-workflow/theme-generation.md',
'/skills/axure-prototype-workflow/doc-generation.md',
'/skills/axure-prototype-workflow/data-generation.md',
'/skills/web-page-workflow/theme-generation.md',
'/skills/web-page-workflow/doc-generation.md',
'/skills/web-page-workflow/data-generation.md',
];
const CODE_EXTENSIONS = ['.tsx', '.ts', '.jsx', '.js', '.mjs', '.cjs'];
const IGNORED_DIRS = new Set(['node_modules', '.npm-local-cache', 'build']);
const CONFIG = {
projectRoot: path.resolve(__dirname, '..'),
};
function log(message, type = 'info') {
const prefix = { info: '✓', warn: '⚠', error: '✗', progress: '⏳' }[type] || '';
console.log(`${prefix} ${message}`);
}
function ensureDir(dirPath) {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
}
function sanitizeName(rawName) {
return String(rawName || '')
.replace(/[^a-z0-9-]/gi, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
.toLowerCase();
}
function normalizeSlashes(input) {
return String(input || '').replace(/\\/g, '/');
}
function ensureRelativeSpecifier(specifier) {
const normalized = normalizeSlashes(specifier);
if (normalized.startsWith('.')) return normalized;
return `./${normalized}`;
}
function countUniqueCssVariables(content) {
const matches = content.matchAll(/--([a-z0-9-_]+)\s*:/gi);
const names = new Set();
for (const match of matches) {
if (match[1]) {
names.add(match[1]);
}
}
return names.size;
}
function getTargetInfo(targetType, outputName) {
const srcDir = TARGET_TYPE_TO_SRC_DIR[targetType];
const outputBaseDir = path.resolve(CONFIG.projectRoot, srcDir);
const outputDir = path.join(outputBaseDir, outputName);
const relativeOutputDir = `${srcDir}/${outputName}`;
if (targetType === 'themes') {
return {
targetType,
srcDir,
outputBaseDir,
outputDir,
relativeOutputDir,
tasksFileName: '.figma-make-theme-tasks.md',
analysisFileName: '.figma-make-theme-analysis.json',
checkPath: `/themes/${outputName}`,
label: '主题',
};
}
return {
targetType,
srcDir,
outputBaseDir,
outputDir,
relativeOutputDir,
tasksFileName: '.figma-make-tasks.md',
analysisFileName: '.figma-make-analysis.json',
checkPath: `/${targetType}/${outputName}`,
label: targetType === 'components' ? '组件' : '页面',
};
}
function walkFiles(dir, options = {}) {
const {
extensions = null,
includeAllFiles = false,
} = options;
const results = [];
if (!fs.existsSync(dir)) return results;
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
if (IGNORED_DIRS.has(entry.name)) {
continue;
}
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
results.push(...walkFiles(fullPath, options));
continue;
}
if (includeAllFiles) {
results.push(fullPath);
continue;
}
const ext = path.extname(entry.name);
if (!extensions || extensions.includes(ext)) {
results.push(fullPath);
}
}
return results;
}
function copyDirectory(src, dest) {
if (!fs.existsSync(src)) return 0;
ensureDir(dest);
const entries = fs.readdirSync(src, { withFileTypes: true });
let count = 0;
for (const entry of entries) {
if (IGNORED_DIRS.has(entry.name)) {
continue;
}
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
count += copyDirectory(srcPath, destPath);
continue;
}
fs.copyFileSync(srcPath, destPath);
count += 1;
}
return count;
}
function parsePackageSpecifier(specifier) {
if (!specifier || specifier.startsWith('.') || specifier.startsWith('/') || specifier.startsWith('#')) {
return null;
}
if (specifier.startsWith('@/')) {
return null;
}
if (specifier.startsWith('@')) {
const firstSlash = specifier.indexOf('/');
if (firstSlash === -1) return null;
const secondSlash = specifier.indexOf('/', firstSlash + 1);
const packageWithVersion = secondSlash === -1 ? specifier : specifier.slice(0, secondSlash);
const rest = secondSlash === -1 ? '' : specifier.slice(secondSlash);
const match = packageWithVersion.match(/^(@[^/]+\/[^@/]+)@(.+)$/);
if (!match) return null;
return {
packageName: match[1],
version: match[2],
normalized: `${match[1]}${rest}`,
};
}
const slashIndex = specifier.indexOf('/');
const packageWithVersion = slashIndex === -1 ? specifier : specifier.slice(0, slashIndex);
const rest = slashIndex === -1 ? '' : specifier.slice(slashIndex);
const match = packageWithVersion.match(/^([^@/]+)@(.+)$/);
if (!match) return null;
return {
packageName: match[1],
version: match[2],
normalized: `${match[1]}${rest}`,
};
}
function replaceAliasAndVersionImports(targetDir) {
const srcRoot = path.join(targetDir, 'src');
const files = walkFiles(targetDir, { extensions: CODE_EXTENSIONS });
const pathAliases = [];
const versionedImports = [];
let processedCount = 0;
for (const file of files) {
let content = fs.readFileSync(file, 'utf8');
const originalContent = content;
const relativeFilePath = normalizeSlashes(path.relative(targetDir, file));
content = content.replace(
/((?:from|import|export)\s*(?:[^'"]*?\sfrom\s*)?['"])@\/([^'"]+)(['"])/g,
(fullMatch, prefix, targetPath, suffix) => {
const resolvedTarget = path.join(srcRoot, targetPath);
const relativeSpecifier = ensureRelativeSpecifier(path.relative(path.dirname(file), resolvedTarget));
pathAliases.push({
file: relativeFilePath,
original: `@/${targetPath}`,
replacement: normalizeSlashes(relativeSpecifier),
});
return `${prefix}${normalizeSlashes(relativeSpecifier)}${suffix}`;
},
);
content = content.replace(
/(\bimport\s*\(\s*['"])([^'"]+)(['"]\s*\))/g,
(fullMatch, prefix, specifier, suffix) => {
if (specifier.startsWith('@/')) {
const targetPath = specifier.slice(2);
const resolvedTarget = path.join(srcRoot, targetPath);
const relativeSpecifier = ensureRelativeSpecifier(path.relative(path.dirname(file), resolvedTarget));
pathAliases.push({
file: relativeFilePath,
original: specifier,
replacement: normalizeSlashes(relativeSpecifier),
});
return `${prefix}${normalizeSlashes(relativeSpecifier)}${suffix}`;
}
const parsed = parsePackageSpecifier(specifier);
if (!parsed) return fullMatch;
versionedImports.push({
file: relativeFilePath,
original: specifier,
replacement: parsed.normalized,
packageName: parsed.packageName,
version: parsed.version,
});
return `${prefix}${parsed.normalized}${suffix}`;
},
);
content = content.replace(
/((?:from|import|export)\s*(?:[^'"]*?\sfrom\s*)?['"])([^'"]+)(['"])/g,
(fullMatch, prefix, specifier, suffix) => {
const parsed = parsePackageSpecifier(specifier);
if (!parsed) return fullMatch;
versionedImports.push({
file: relativeFilePath,
original: specifier,
replacement: parsed.normalized,
packageName: parsed.packageName,
version: parsed.version,
});
return `${prefix}${parsed.normalized}${suffix}`;
},
);
if (content !== originalContent) {
fs.writeFileSync(file, content);
processedCount += 1;
}
}
return {
processedCount,
pathAliases,
versionedImports,
};
}
function parseImports(content) {
const imports = [];
const importMatches = content.matchAll(/(?:from\s+['"]([^'"]+)['"]|import\s*\(\s*['"]([^'"]+)['"]\s*\))/g);
for (const match of importMatches) {
const specifier = match[1] || match[2];
if (specifier) {
imports.push(specifier);
}
}
return imports;
}
function parseVersionedAliases(viteContent) {
const aliasMap = new Map();
const objectMatches = viteContent.matchAll(/['"]([^'"]+@[^'"]+)['"]\s*:\s*['"]([^'"]+)['"]/g);
for (const match of objectMatches) {
aliasMap.set(match[1], match[2]);
}
const arrayMatches = viteContent.matchAll(/find\s*:\s*['"]([^'"]+@[^'"]+)['"][\s\S]*?replacement\s*:\s*['"]([^'"]+)['"]/g);
for (const match of arrayMatches) {
aliasMap.set(match[1], match[2]);
}
return Array.from(aliasMap.entries()).map(([find, replacement]) => {
const parsed = parsePackageSpecifier(find);
return {
find,
replacement,
packageName: parsed?.packageName || replacement,
version: parsed?.version || '',
};
});
}
function detectAtAlias(viteContent) {
const objectMatch = viteContent.match(/['"]@['"]\s*:\s*['"]([^'"]+)['"]/);
if (objectMatch) return objectMatch[1];
const arrayMatch = viteContent.match(/find\s*:\s*['"]@['"][\s\S]*?replacement\s*:\s*['"]([^'"]+)['"]/);
return arrayMatch ? arrayMatch[1] : '';
}
function resolveOptionalPath(targetDir, candidates) {
for (const candidate of candidates) {
const fullPath = path.join(targetDir, candidate);
if (fs.existsSync(fullPath)) {
return {
exists: true,
path: candidate,
absolutePath: fullPath,
};
}
}
return {
exists: false,
path: '',
absolutePath: '',
};
}
function countFilesRecursive(dirPath) {
if (!fs.existsSync(dirPath)) return 0;
let total = 0;
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name);
if (entry.isDirectory()) {
total += countFilesRecursive(fullPath);
} else if (entry.isFile()) {
total += 1;
}
}
return total;
}
function collectFigmaMakeAssets(targetDir) {
const imagesDir = path.join(targetDir, 'images');
return {
hasCanvasFig: fs.existsSync(path.join(targetDir, 'canvas.fig')),
hasMetaJson: fs.existsSync(path.join(targetDir, 'meta.json')),
hasAiChat: fs.existsSync(path.join(targetDir, 'ai_chat.json')),
hasThumbnail: fs.existsSync(path.join(targetDir, 'thumbnail.png')),
hasImagesDir: fs.existsSync(imagesDir),
hasCodeManifest: fs.existsSync(path.join(targetDir, 'canvas.code-manifest.json')),
imageCount: countFilesRecursive(imagesDir),
};
}
function analyzeProject(targetDir, conversionResult) {
const srcDir = path.join(targetDir, 'src');
const allFiles = walkFiles(targetDir, { includeAllFiles: true });
const codeFiles = walkFiles(targetDir, { extensions: CODE_EXTENSIONS });
const pageFiles = walkFiles(path.join(srcDir, 'pages'), { extensions: ['.tsx', '.ts', '.jsx', '.js'] })
.map((file) => normalizeSlashes(path.relative(targetDir, file)));
const componentFiles = walkFiles(path.join(srcDir, 'components'), { extensions: ['.tsx', '.ts', '.jsx', '.js'] })
.map((file) => normalizeSlashes(path.relative(targetDir, file)));
const files = codeFiles.map((file) => {
const relativePath = normalizeSlashes(path.relative(targetDir, file));
const content = fs.readFileSync(file, 'utf8');
const imports = parseImports(content);
return {
path: relativePath,
importCount: imports.length,
imports: imports.slice(0, 25),
containsRouteHint: /createBrowserRouter|Routes|Route|react-router/i.test(content),
usesCssVariables: /var\(--[a-z0-9-_]+\)/i.test(content),
};
});
const packageJsonPath = path.join(targetDir, 'package.json');
let dependencies = {
all: {},
toInstall: [],
excluded: ['react', 'react-dom', 'next-themes'],
};
if (fs.existsSync(packageJsonPath)) {
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
const allDeps = packageJson.dependencies || {};
dependencies = {
all: allDeps,
toInstall: Object.keys(allDeps).filter((dep) => {
return dep !== 'react' && dep !== 'react-dom' && dep !== 'next-themes';
}),
excluded: ['react', 'react-dom', 'next-themes'],
};
}
const viteConfigInfo = resolveOptionalPath(targetDir, ['vite.config.ts', 'vite.config.js', 'vite.config.mts', 'vite.config.mjs']);
const viteContent = viteConfigInfo.exists ? fs.readFileSync(viteConfigInfo.absolutePath, 'utf8') : '';
const versionedAliases = viteContent ? parseVersionedAliases(viteContent) : [];
const atAliasReplacement = viteContent ? detectAtAlias(viteContent) : '';
const indexCssInfo = resolveOptionalPath(targetDir, ['src/index.css']);
const globalsCssInfo = resolveOptionalPath(targetDir, ['src/styles/globals.css']);
const designGuideInfo = resolveOptionalPath(targetDir, ['src/DESIGN_SYSTEM_GUIDE.md', 'DESIGN_SYSTEM_GUIDE.md']);
const tokenReferenceInfo = resolveOptionalPath(targetDir, ['src/TOKEN_REFERENCE.md', 'TOKEN_REFERENCE.md']);
const figmaMakeAssets = collectFigmaMakeAssets(targetDir);
const indexCssContent = indexCssInfo.exists ? fs.readFileSync(indexCssInfo.absolutePath, 'utf8') : '';
const globalsCssContent = globalsCssInfo.exists ? fs.readFileSync(globalsCssInfo.absolutePath, 'utf8') : '';
return {
summary: {
totalFiles: allFiles.length,
codeFileCount: codeFiles.length,
componentCount: componentFiles.length,
pageCount: pageFiles.length,
pathAliasCount: conversionResult.pathAliases.length,
versionedImportCount: conversionResult.versionedImports.length,
versionedAliasCount: versionedAliases.length,
dependenciesToInstall: dependencies.toInstall.length,
cssVariableCount: countUniqueCssVariables(`${indexCssContent}\n${globalsCssContent}`),
designDocCount: [designGuideInfo.exists, tokenReferenceInfo.exists].filter(Boolean).length,
},
structure: {
hasSrcDir: fs.existsSync(srcDir),
hasAppTsx: fs.existsSync(path.join(srcDir, 'App.tsx')),
hasMainTsx: fs.existsSync(path.join(srcDir, 'main.tsx')),
hasPagesDir: fs.existsSync(path.join(srcDir, 'pages')),
hasComponentsDir: fs.existsSync(path.join(srcDir, 'components')),
hasGuidelinesDir: fs.existsSync(path.join(srcDir, 'guidelines')),
hasBuildDir: fs.existsSync(path.join(targetDir, 'build')),
hasViteConfig: viteConfigInfo.exists,
hasIndexHtml: fs.existsSync(path.join(targetDir, 'index.html')),
hasIndexCss: indexCssInfo.exists,
hasGlobalsCss: globalsCssInfo.exists,
},
entryFiles: {
appTsx: fs.existsSync(path.join(srcDir, 'App.tsx')) ? 'src/App.tsx' : '',
mainTsx: fs.existsSync(path.join(srcDir, 'main.tsx')) ? 'src/main.tsx' : '',
indexHtml: fs.existsSync(path.join(targetDir, 'index.html')) ? 'index.html' : '',
pagesDir: fs.existsSync(path.join(srcDir, 'pages')) ? 'src/pages' : '',
pageFiles,
},
pathAliases: conversionResult.pathAliases,
versionedImports: conversionResult.versionedImports,
versionedAliases,
dependencies,
css: {
hasIndexCss: indexCssInfo.exists,
indexCssPath: indexCssInfo.path,
indexCssSize: indexCssContent.length,
hasGlobalsCss: globalsCssInfo.exists,
globalsCssPath: globalsCssInfo.path,
globalsCssSize: globalsCssContent.length,
cssVariableCount: countUniqueCssVariables(`${indexCssContent}\n${globalsCssContent}`),
prefersGlobalsCssAsSource: globalsCssInfo.exists,
atAliasReplacement,
},
docs: {
designSystemGuide: {
exists: designGuideInfo.exists,
path: designGuideInfo.path,
},
tokenReference: {
exists: tokenReferenceInfo.exists,
path: tokenReferenceInfo.path,
},
},
figmaMakeAssets,
files,
};
}
function writeAnalysisReport(report, targetInfo) {
const reportPath = path.join(targetInfo.outputDir, targetInfo.analysisFileName);
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
return reportPath;
}
function renderDocLine(docInfo) {
return docInfo.exists ? `\`${docInfo.path}\`` : '未提供';
}
function generateDefaultTasksDocument(report, targetInfo, outputName, tempDir) {
const reportPath = writeAnalysisReport(report, targetInfo);
let markdown = '# Figma Make 项目转换任务清单\n\n';
markdown += '> **重要**: 请先阅读 `/skills/figma-make-project-converter/SKILL.md` 了解转换规范\n\n';
markdown += `**名称**: ${outputName}\n`;
markdown += `**项目位置**: \`${targetInfo.relativeOutputDir}/\`\n`;
markdown += `**原始文件**: \`${tempDir}\` (仅供参考,不要修改)\n`;
markdown += `**生成时间**: ${new Date().toLocaleString()}\n\n`;
if (
report.figmaMakeAssets.hasCanvasFig
|| report.figmaMakeAssets.hasMetaJson
|| report.figmaMakeAssets.hasAiChat
|| report.figmaMakeAssets.hasThumbnail
|| report.figmaMakeAssets.hasImagesDir
|| report.figmaMakeAssets.hasCodeManifest
) {
markdown += '## ⚠️ 注意:保留 Figma Make 原始资产\n\n';
markdown += '此项目从 Figma Make 导入,以下文件必须保留,用于后续导出 `名称.fig`\n';
if (report.figmaMakeAssets.hasCanvasFig) markdown += '- `canvas.fig` — Figma 设计数据(二进制,勿修改)\n';
if (report.figmaMakeAssets.hasMetaJson) markdown += '- `meta.json` — 项目元数据\n';
if (report.figmaMakeAssets.hasAiChat) markdown += '- `ai_chat.json` — AI 聊天历史\n';
if (report.figmaMakeAssets.hasThumbnail) markdown += '- `thumbnail.png` — 项目缩略图\n';
if (report.figmaMakeAssets.hasCodeManifest) markdown += '- `canvas.code-manifest.json` — CODE_FILE 索引清单\n';
if (report.figmaMakeAssets.hasImagesDir) {
markdown += `- \`images/\` — 设计稿图片资源(当前 ${report.figmaMakeAssets.imageCount} 个文件)\n`;
}
markdown += '\n';
}
markdown += '## 📊 项目概况\n\n';
markdown += `- 总文件数: ${report.summary.totalFiles}\n`;
markdown += `- 组件数: ${report.summary.componentCount}\n`;
markdown += `- 页面数: ${report.summary.pageCount}\n`;
markdown += `- ~~路径别名 (@/)~~: ${report.summary.pathAliasCount} 处,已由脚本转换\n`;
markdown += `- ~~版本化导入~~: ${report.summary.versionedImportCount} 处,已改为裸包名\n`;
markdown += `- vite 版本化 alias: ${report.summary.versionedAliasCount} 处(仅保留为参考信息)\n`;
markdown += `- 待安装依赖: ${report.summary.dependenciesToInstall}\n`;
markdown += `- CSS 变量: ${report.summary.cssVariableCount}\n\n`;
markdown += '## 🧱 固定目录结构(必须遵守)\n\n';
markdown += '```text\n';
markdown += `${targetInfo.relativeOutputDir}/\n`;
markdown += '├── index.tsx # Axhub runtime adapter only\n';
markdown += '├── style.css # root style bridge only\n';
markdown += '└── src/\n';
markdown += ' ├── App.tsx # Figma export shell only\n';
markdown += ' ├── main.tsx # Vite mount only\n';
markdown += ' ├── index.css # Figma style bridge only\n';
markdown += ' ├── components/ # shared page implementation\n';
markdown += ' └── styles/ # shared page styles\n';
markdown += '```\n\n';
markdown += '要求:\n';
markdown += '- 页面真实视觉和交互主体优先沉淀在 `src/components/**`\n';
markdown += '- 根目录 `index.tsx` 只做 Axhub 运行时适配,不复制页面视觉实现\n';
markdown += '- `src/App.tsx` 只做 Figma 导出薄壳,不复制页面逻辑\n';
markdown += '- `style.css` / `src/index.css` 只做样式桥接,避免重复堆样式\n';
markdown += '- 在 `index.tsx`、`src/App.tsx`、`src/main.tsx` 顶部写职责注释,提醒后续维护者不要让入口漂移\n\n';
markdown += '> 若最终项目不符合这套固定结构,视为转换未完成,应继续重构后再进入后续任务。\n\n';
markdown += '## ✅ 转换任务(共 6 个)\n\n';
markdown += '### 任务 1: 确定页面入口并创建 `index.tsx`\n\n';
markdown += `**候选入口**:\n`;
if (report.entryFiles.appTsx) markdown += `- \`${targetInfo.relativeOutputDir}/${report.entryFiles.appTsx}\`\n`;
if (report.entryFiles.mainTsx) markdown += `- \`${targetInfo.relativeOutputDir}/${report.entryFiles.mainTsx}\`\n`;
if (report.entryFiles.pageFiles.length > 0) {
markdown += `- \`${targetInfo.relativeOutputDir}/src/pages/\` 下共有 ${report.entryFiles.pageFiles.length} 个页面文件\n`;
}
markdown += '\n';
markdown += '**操作**:\n';
markdown += '1. 按照 `/skills/figma-make-project-converter/SKILL.md` 的页面组件规范创建 `index.tsx`\n';
markdown += '2. 优先使用 `src/App.tsx` 作为汇总入口;若存在多页面路由,则收敛为本项目单入口组件\n';
markdown += '3. 不保留对 Figma Make 原始 `main.tsx` 挂载逻辑的依赖\n';
markdown += '4. 在 `index.tsx` 顶部写注释,明确它只是 Axhub runtime adapter\n\n';
markdown += '> 若页面后续还要重新导出 `名称.fig`,请确保导出壳子 `src/App.tsx` 最终仍能表达当前页面真实内容,避免它与根目录 `index.tsx` 漂移。\n\n';
markdown += '### 任务 2: 创建 `style.css`\n\n';
markdown += '**目标**: 按约定使用 `globals.css` 作为主样式来源\n\n';
markdown += '**操作**:\n';
markdown += '1. 创建 `style.css`,第一行固定为 `@import "tailwindcss";`\n';
if (report.css.hasGlobalsCss) {
markdown += `2. 以 \`${targetInfo.relativeOutputDir}/${report.css.globalsCssPath}\` 作为主要样式来源\n`;
} else {
markdown += '2. 未发现 `src/styles/globals.css`,需要 AI 从现有组件样式中补齐基础样式\n';
}
if (report.css.hasIndexCss) {
markdown += `3. \`${targetInfo.relativeOutputDir}/${report.css.indexCssPath}\` 仅作为视觉回归参考,不直接搬运为最终 \`style.css\`\n`;
}
markdown += '4. 在 `style.css` 与 `src/index.css` 中保留注释,明确它们只是样式桥接层\n';
markdown += '\n';
markdown += '### 任务 3: 清理 Figma Make 运行时耦合\n\n';
markdown += '**脚本已完成**:\n';
markdown += `- ~~转换 \`@/\` 路径别名~~ ✓ 已完成(${report.summary.pathAliasCount} 处)\n`;
markdown += `- ~~转换 \`package@version\` 导入~~ ✓ 已完成(${report.summary.versionedImportCount} 处)\n\n`;
markdown += '**仍需处理**:\n';
markdown += '- 不再依赖 `vite.config.ts` 中的 alias 作为运行前提\n';
markdown += '- 可保留 `vite.config.ts` 作为参考,但最终组件需独立运行\n';
markdown += '- 不保留原始 Vite 挂载入口与多页面路由壳层\n';
markdown += '- `src/App.tsx` 与 `src/main.tsx` 顶部要写职责注释,避免后续维护时误塞页面逻辑\n\n';
markdown += '### 任务 4: 收敛多页面/路由结构\n\n';
markdown += '**目标**: 将 Figma Make 的多页面应用收敛为本项目单入口页面组件\n\n';
markdown += '**操作**:\n';
markdown += '- 保留页面视觉层级与主要交互结构\n';
markdown += '- 将路由切换逻辑合并为单页面展示或局部状态切换\n';
markdown += '- 不强行保留浏览器路由壳层\n\n';
markdown += '### 任务 5: 安装依赖\n\n';
if (report.dependencies.toInstall.length > 0) {
markdown += '**执行命令**:\n';
markdown += '```bash\n';
markdown += `pnpm add ${report.dependencies.toInstall.join(' ')}\n`;
markdown += '```\n\n';
markdown += `**已排除**: ${report.dependencies.excluded.map((item) => `\`${item}\``).join('、')}\n\n`;
} else {
markdown += '✓ 无需安装额外依赖\n\n';
}
markdown += '### 任务 6: 验收测试\n\n';
markdown += '**执行命令**:\n';
markdown += '```bash\n';
markdown += `node scripts/check-app-ready.mjs ${targetInfo.checkPath}\n`;
markdown += '```\n\n';
markdown += '**验收要求**: 页面正常渲染、无控制台错误、主视觉与原项目一致\n\n';
markdown += '## 📚 可直接利用的设计资料\n\n';
markdown += `- DESIGN_SYSTEM_GUIDE.md: ${renderDocLine(report.docs.designSystemGuide)}\n`;
markdown += `- TOKEN_REFERENCE.md: ${renderDocLine(report.docs.tokenReference)}\n\n`;
markdown += '## 📎 产物索引\n\n';
markdown += `- 任务清单: \`${targetInfo.tasksFileName}\`\n`;
markdown += `- 分析报告: \`${targetInfo.analysisFileName}\`\n`;
markdown += '- 转换规范: `/skills/figma-make-project-converter/SKILL.md`\n';
const mdPath = path.join(targetInfo.outputDir, targetInfo.tasksFileName);
fs.writeFileSync(mdPath, markdown);
return { reportPath, mdPath };
}
function generateThemeTasksDocument(report, targetInfo, outputName, tempDir) {
const reportPath = writeAnalysisReport(report, targetInfo);
let markdown = '# Figma Make 主题导入任务清单\n\n';
markdown += '> **重要**: 请先阅读 `/skills/figma-make-project-converter/SKILL.md` 与主题拆分技能文档,按任务顺序执行\n\n';
markdown += `**主题 key**: ${outputName}\n`;
markdown += `**主题目录**: \`${targetInfo.relativeOutputDir}/\`\n`;
markdown += `**原始文件**: \`${tempDir}\` (仅供参考,不要修改)\n`;
markdown += `**生成时间**: ${new Date().toLocaleString()}\n\n`;
if (
report.figmaMakeAssets.hasCanvasFig
|| report.figmaMakeAssets.hasMetaJson
|| report.figmaMakeAssets.hasAiChat
|| report.figmaMakeAssets.hasThumbnail
|| report.figmaMakeAssets.hasImagesDir
|| report.figmaMakeAssets.hasCodeManifest
) {
markdown += '## ⚠️ 注意:保留 Figma Make 原始资产\n\n';
markdown += '此项目从 Figma Make 导入,以下文件必须保留,用于后续导出 `名称.fig`\n';
if (report.figmaMakeAssets.hasCanvasFig) markdown += '- `canvas.fig` — Figma 设计数据(二进制,勿修改)\n';
if (report.figmaMakeAssets.hasMetaJson) markdown += '- `meta.json` — 项目元数据\n';
if (report.figmaMakeAssets.hasAiChat) markdown += '- `ai_chat.json` — AI 聊天历史\n';
if (report.figmaMakeAssets.hasThumbnail) markdown += '- `thumbnail.png` — 项目缩略图\n';
if (report.figmaMakeAssets.hasCodeManifest) markdown += '- `canvas.code-manifest.json` — CODE_FILE 索引清单\n';
if (report.figmaMakeAssets.hasImagesDir) {
markdown += `- \`images/\` — 设计稿图片资源(当前 ${report.figmaMakeAssets.imageCount} 个文件)\n`;
}
markdown += '\n';
}
markdown += '## 📊 输入概况\n\n';
markdown += `- 总文件数: ${report.summary.totalFiles}\n`;
markdown += `- 组件数: ${report.summary.componentCount}\n`;
markdown += `- 页面数: ${report.summary.pageCount}\n`;
markdown += `- CSS 变量: ${report.summary.cssVariableCount}\n`;
markdown += `- 版本化 alias: ${report.summary.versionedAliasCount}\n`;
markdown += `- 待评估依赖: ${report.summary.dependenciesToInstall}\n\n`;
markdown += '## 📚 参考文档(必须阅读)\n\n';
markdown += '- `/skills/figma-make-project-converter/SKILL.md`\n';
THEME_SPLIT_SKILL_DOCS.forEach((docPath) => {
markdown += `- \`${docPath}\`\n`;
});
markdown += '\n';
markdown += '## 🧭 可利用的设计输入\n\n';
markdown += `- DESIGN_SYSTEM_GUIDE.md: ${renderDocLine(report.docs.designSystemGuide)}\n`;
markdown += `- TOKEN_REFERENCE.md: ${renderDocLine(report.docs.tokenReference)}\n`;
markdown += report.css.hasGlobalsCss
? `- 主题主样式参考: \`${targetInfo.relativeOutputDir}/${report.css.globalsCssPath}\`\n`
: '- 主题主样式参考: 未找到 `src/styles/globals.css`\n';
markdown += report.css.hasIndexCss
? `- 视觉回归参考: \`${targetInfo.relativeOutputDir}/${report.css.indexCssPath}\`\n\n`
: '- 视觉回归参考: 未找到 `src/index.css`\n\n';
markdown += '## ✅ 主题导入任务(共 5 个)\n\n';
markdown += '### 任务 1生成主题 token\n\n';
markdown += `**目标**:在 \`${targetInfo.relativeOutputDir}/\` 下生成 \`globals.css\`\`designToken.json\`(二选一)\n\n`;
markdown += '**要求**\n';
markdown += '- 优先利用设计文档、CSS 变量和全局样式提取颜色、字体、间距、圆角、阴影 token\n';
markdown += '- 若输出 `designToken.json`,必须包含 `name` 字段\n\n';
markdown += '### 任务 2生成 DESIGN-SPEC.md\n\n';
markdown += `**目标**:输出 \`${targetInfo.relativeOutputDir}/DESIGN-SPEC.md\`\n\n`;
markdown += '**要求**:说明设计语言、组件风格、排版层级、状态与使用建议\n\n';
markdown += '### 任务 3按需生成项目文档\n\n';
markdown += '**目标**:在 `src/docs/` 下补充主题相关文档\n\n';
markdown += '**要求**:结合输入项目的信息架构与设计文档产出高可读说明\n\n';
markdown += '### 任务 4按需生成数据模型\n\n';
markdown += '**目标**:在 `src/database/` 下补充或更新数据模型\n\n';
markdown += '**要求**:文件名英文、`tableName` 中文、`records` 数组中 `id` 唯一\n\n';
markdown += '### 任务 5生成/更新主题演示入口\n\n';
markdown += `**目标**:生成或更新 \`${targetInfo.relativeOutputDir}/index.tsx\`\n\n`;
markdown += '**要求**:明确演示 token、生效方式与关键组件外观\n\n';
markdown += '## 📎 产物索引\n\n';
markdown += `- 任务清单: \`${targetInfo.tasksFileName}\`\n`;
markdown += `- 分析报告: \`${targetInfo.analysisFileName}\`\n`;
const mdPath = path.join(targetInfo.outputDir, targetInfo.tasksFileName);
fs.writeFileSync(mdPath, markdown);
return { reportPath, mdPath };
}
function parseArgs(rawArgs) {
const args = [...rawArgs];
const help = args.length === 0 || args.includes('--help') || args.includes('-h');
if (help) {
return { help: true };
}
let projectDirArg = '';
let outputNameArg = '';
let targetType = 'prototypes';
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === '--target-type') {
const nextValue = args[index + 1];
if (!nextValue) {
throw new Error('参数 --target-type 缺少值');
}
targetType = String(nextValue).trim();
index += 1;
continue;
}
if (!projectDirArg) {
projectDirArg = arg;
continue;
}
if (!outputNameArg) {
outputNameArg = arg;
continue;
}
}
if (!projectDirArg) {
throw new Error('缺少 <figma-make-project-dir> 参数');
}
if (!Object.prototype.hasOwnProperty.call(TARGET_TYPE_TO_SRC_DIR, targetType)) {
throw new Error(`不支持的 targetType: ${targetType}。可选值: ${Object.keys(TARGET_TYPE_TO_SRC_DIR).join(', ')}`);
}
const outputName = sanitizeName(outputNameArg || path.basename(projectDirArg));
if (!outputName) {
throw new Error('无法生成有效的输出名称,请显式传入 [output-name]');
}
return {
help: false,
projectDirArg,
outputName,
targetType,
};
}
function printHelp() {
console.log(`
Figma Make 项目预处理器
使用方法:
node scripts/figma-make-converter.mjs <figma-make-project-dir> [output-name] [--target-type <prototypes|components|themes>]
示例:
node scripts/figma-make-converter.mjs "temp/my-figma-make-project" my-page
node scripts/figma-make-converter.mjs "temp/my-figma-make-project" brand-theme --target-type themes
功能:
- 输入目录应来自 Figma 原始导出的 ZIP 工程包解压结果
- 完整复制 Figma Make 项目(排除 node_modules / .npm-local-cache / build
- 转换 @/ 路径别名
- 转换 package@version 导入
- 生成 AI 工作文档(默认 .figma-make-tasks.md主题模式 .figma-make-theme-tasks.md
- 生成分析报告(默认 .figma-make-analysis.json主题模式 .figma-make-theme-analysis.json
`);
}
async function main() {
let parsed;
try {
parsed = parseArgs(process.argv.slice(2));
} catch (error) {
log(`参数错误: ${error.message}`, 'error');
printHelp();
process.exit(1);
}
if (parsed.help) {
printHelp();
process.exit(0);
}
const figmaMakeDir = path.resolve(CONFIG.projectRoot, parsed.projectDirArg);
const targetInfo = getTargetInfo(parsed.targetType, parsed.outputName);
if (!fs.existsSync(figmaMakeDir)) {
log(`错误: 找不到目录 ${figmaMakeDir}`, 'error');
process.exit(1);
}
const srcDir = path.join(figmaMakeDir, 'src');
const packageJsonPath = path.join(figmaMakeDir, 'package.json');
const hasAppTsx = fs.existsSync(path.join(srcDir, 'App.tsx'));
const hasMainTsx = fs.existsSync(path.join(srcDir, 'main.tsx'));
if (!fs.existsSync(srcDir) || !fs.existsSync(packageJsonPath) || (!hasAppTsx && !hasMainTsx)) {
log('错误: 这不是一个有效的 Figma Make 项目(需要包含 src/、package.json以及 src/App.tsx 或 src/main.tsx', 'error');
process.exit(1);
}
try {
ensureDir(targetInfo.outputBaseDir);
log(`开始预处理 Figma Make 项目targetType=${parsed.targetType}...`, 'info');
log('步骤 1/4: 复制项目文件...', 'progress');
const fileCount = copyDirectory(figmaMakeDir, targetInfo.outputDir);
log(`已复制 ${fileCount} 个文件`, 'info');
log('步骤 2/4: 处理确定性转换(@/ 与 package@version...', 'progress');
const conversionResult = replaceAliasAndVersionImports(targetInfo.outputDir);
log(`已处理 ${conversionResult.processedCount} 个文件`, 'info');
log('步骤 3/4: 分析项目结构...', 'progress');
const report = analyzeProject(targetInfo.outputDir, conversionResult);
log(`发现 ${report.summary.componentCount} 个组件,${report.summary.pageCount} 个页面`, 'info');
log('步骤 4/4: 生成任务文档...', 'progress');
const { reportPath, mdPath } = parsed.targetType === 'themes'
? generateThemeTasksDocument(report, targetInfo, parsed.outputName, `temp/${path.basename(figmaMakeDir)}`)
: generateDefaultTasksDocument(report, targetInfo, parsed.outputName, `temp/${path.basename(figmaMakeDir)}`);
log('✅ 预处理完成!', 'info');
log('', 'info');
log(`📁 ${targetInfo.label}位置: ${targetInfo.relativeOutputDir}/`, 'info');
log(`📋 AI 工作文档: ${path.relative(CONFIG.projectRoot, mdPath)}`, 'info');
log(`📊 详细数据: ${path.relative(CONFIG.projectRoot, reportPath)}`, 'info');
log('', 'info');
log('📈 统计:', 'info');
log(` - 文件数: ${report.summary.totalFiles}`, 'info');
log(` - 组件数: ${report.summary.componentCount}`, 'info');
log(` - 页面数: ${report.summary.pageCount}`, 'info');
log(` - CSS 变量: ${report.summary.cssVariableCount}`, 'info');
log('', 'info');
log('🎯 下一步:', 'info');
log(`1. 查看任务文档: cat ${path.relative(CONFIG.projectRoot, mdPath)}`, 'info');
log(parsed.targetType === 'themes'
? '2. 让 AI 按任务单完成主题/文档/数据生成'
: '2. 让 AI 根据任务清单完成转换', 'info');
} catch (error) {
log(`预处理失败: ${error.message}`, 'error');
console.error(error);
process.exit(1);
}
}
main();

View File

@@ -0,0 +1,121 @@
#!/usr/bin/env node
/**
* 为 dist 目录下的所有 JS 文件生成对应的 HTML 文件
* 使用 html-template.html 模板,用于构建后直接访问
*/
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const workspaceRoot = path.resolve(__dirname, '..');
const distDir = path.resolve(workspaceRoot, 'dist');
const adminDir = path.resolve(workspaceRoot, 'admin');
const templatePath = path.join(adminDir, 'html-template.html');
let generatedCount = 0;
// 检查模板是否存在
if (!fs.existsSync(templatePath)) {
console.error('错误: html-template.html 模板不存在,请先构建 prototype-admin');
console.error('路径:', templatePath);
process.exit(1);
}
// 检查 dist 目录是否存在
if (!fs.existsSync(distDir)) {
console.log('dist 目录不存在,跳过 HTML 生成');
process.exit(0);
}
const template = fs.readFileSync(templatePath, 'utf8');
/**
* 递归遍历目录,为每个 JS 文件生成 HTML
*/
function generateHtmlForDirectory(dir, baseDir = distDir) {
const items = fs.readdirSync(dir, { withFileTypes: true });
for (const item of items) {
const itemPath = path.join(dir, item.name);
if (item.isDirectory()) {
// 递归处理子目录
generateHtmlForDirectory(itemPath, baseDir);
} else if (item.name.endsWith('.js') && !item.name.includes('.worker')) {
// 为 JS 文件生成 HTML
const relativePath = path.relative(baseDir, itemPath);
const jsName = relativePath.replace('.js', '');
const htmlPath = path.join(dir, item.name.replace('.js', '.html'));
// 根据路径生成标题
let title = 'Preview';
if (jsName.startsWith('components/')) {
const name = jsName.replace('components/', '');
title = `Element: ${name}`;
} else if (jsName.startsWith('prototypes/')) {
const name = jsName.replace('prototypes/', '');
title = `Page: ${name}`;
}
// 计算 JS 文件的相对路径(从 HTML 的位置)
const jsFileName = `./${item.name}`;
// 计算 bootstrap JS 的相对路径
const relativeDir = path.relative(baseDir, dir);
const depth = relativeDir ? relativeDir.split(path.sep).length : 0;
const bootstrapPath = depth > 0
? '../'.repeat(depth) + 'assets/html-template-bootstrap.js'
: './assets/html-template-bootstrap.js';
// 替换模板变量
let html = template.replace('{{TITLE}}', title);
html = html.replace('{{ENTRY}}', jsFileName);
html = html.replace('{{BOOTSTRAP_PATH}}', bootstrapPath);
// 写入 HTML 文件
fs.writeFileSync(htmlPath, html, 'utf8');
console.log(`生成: ${relativePath.replace('.js', '.html')}`);
generatedCount++;
}
}
}
console.log('\n开始为 dist 目录生成 HTML 文件...\n');
generateHtmlForDirectory(distDir);
console.log(`\n完成!共生成 ${generatedCount} 个 HTML 文件。`);
console.log('模板文件: admin/html-template.html\n');
// 复制 bootstrap JS 到 dist/assets
const srcBootstrap = path.join(adminDir, 'assets/html-template-bootstrap.js');
const destAssetsDir = path.join(distDir, 'assets');
const destBootstrap = path.join(destAssetsDir, 'html-template-bootstrap.js');
if (fs.existsSync(srcBootstrap)) {
if (!fs.existsSync(destAssetsDir)) {
fs.mkdirSync(destAssetsDir, { recursive: true });
}
fs.copyFileSync(srcBootstrap, destBootstrap);
console.log('✓ html-template-bootstrap.js 已复制到 dist/assets');
// 同时复制其他必要的依赖(如果有的话)
const assetsFiles = fs.readdirSync(path.join(adminDir, 'assets'));
for (const file of assetsFiles) {
// 复制 bootstrap 依赖的 chunk 文件
if (file.startsWith('index-') && file.endsWith('.js')) {
const src = path.join(adminDir, 'assets', file);
const dest = path.join(destAssetsDir, file);
fs.copyFileSync(src, dest);
console.log(`${file} 已复制到 dist/assets`);
}
}
} else {
console.warn('⚠ html-template-bootstrap.js 不存在,请先构建 prototype-admin');
}

View File

@@ -0,0 +1,74 @@
#!/usr/bin/env node
/**
* 为所有 components 和 prototypes 生成 index.html 文件
* 使用统一的模板
*/
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const srcDir = path.resolve(__dirname, '../src');
const templatePath = path.join(srcDir, 'common/dev-template.html');
const elementsDir = path.join(srcDir, 'components');
const pagesDir = path.join(srcDir, 'prototypes');
let generatedCount = 0;
// 读取模板
if (!fs.existsSync(templatePath)) {
console.error('错误: 模板文件不存在:', templatePath);
process.exit(1);
}
const template = fs.readFileSync(templatePath, 'utf8');
function generateHtmlFiles(dir, type) {
if (!fs.existsSync(dir)) {
return;
}
const items = fs.readdirSync(dir);
for (const item of items) {
const itemPath = path.join(dir, item);
const stat = fs.statSync(itemPath);
if (stat.isDirectory()) {
const tsxPath = path.join(itemPath, 'index.tsx');
const htmlPath = path.join(itemPath, 'index.html');
// 只有存在 index.tsx 才生成 HTML
if (fs.existsSync(tsxPath)) {
// 生成标题
let title = 'Dev Preview';
if (type === 'components') {
title = `Element: ${item} - Dev Preview`;
} else if (type === 'prototypes') {
title = `Page: ${item} - Dev Preview`;
}
// 替换模板变量
let html = template.replace('{{TITLE}}', title);
html = html.replace('{{ENTRY}}', './index.tsx');
// 写入文件
fs.writeFileSync(htmlPath, html, 'utf8');
console.log(`生成: ${path.relative(srcDir, htmlPath)}`);
generatedCount++;
}
}
}
}
console.log('开始生成 HTML 文件...\n');
generateHtmlFiles(elementsDir, 'components');
generateHtmlFiles(pagesDir, 'prototypes');
console.log(`\n完成!共生成 ${generatedCount} 个 HTML 文件。`);
console.log('所有 HTML 文件都基于统一模板: src/common/dev-template.html');

View File

@@ -0,0 +1,77 @@
#!/usr/bin/env node
import fs from 'fs';
import path from 'path';
import extractZip from 'extract-zip';
function normalizePath(filePath) {
return filePath.split(path.sep).join('/');
}
function truncateName(name, maxLength = 40) {
if (!name) return name;
return name.length > maxLength ? name.slice(0, maxLength) : name;
}
function sanitizeBasename(name) {
return (name || 'axure')
.replace(/[^a-z0-9-]/gi, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
.toLowerCase();
}
function inferContentDir(extractDirAbs) {
const entries = fs
.readdirSync(extractDirAbs, { withFileTypes: true })
.filter(entry => entry.name !== '__MACOSX' && entry.name !== '.DS_Store');
if (entries.length === 1 && entries[0].isDirectory()) {
return path.join(extractDirAbs, entries[0].name);
}
return extractDirAbs;
}
async function main() {
const [zipPathArg, originalFilenameArg] = process.argv.slice(2);
if (!zipPathArg) {
console.error('Usage: local-axure-extract.mjs <zipPath> [originalFilename]');
process.exit(1);
}
const zipPath = path.resolve(process.cwd(), zipPathArg);
if (!fs.existsSync(zipPath)) {
console.error(`Zip not found: ${zipPath}`);
process.exit(1);
}
const originalFilename = originalFilenameArg || path.basename(zipPath);
const baseNameRaw = path.basename(originalFilename, path.extname(originalFilename));
const baseName = truncateName(sanitizeBasename(baseNameRaw));
const timestamp = Date.now();
const extractDirRel = normalizePath(path.join('temp', `local-axure-${baseName}-${timestamp}`));
const extractDirAbs = path.resolve(process.cwd(), extractDirRel);
fs.mkdirSync(extractDirAbs, { recursive: true });
await extractZip(zipPath, { dir: extractDirAbs });
const contentDirAbs = inferContentDir(extractDirAbs);
const contentDirRel = normalizePath(path.relative(process.cwd(), contentDirAbs));
process.stdout.write(
JSON.stringify({
extractDir: extractDirRel,
contentDir: contentDirRel,
}),
);
}
try {
await main();
} catch (error) {
console.error(error);
process.exit(1);
}

View File

@@ -0,0 +1,238 @@
#!/usr/bin/env node
/**
* 批量检查所有组件和页面
*/
import http from 'http';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const HOST = 'localhost';
const PORT = 51720;
function reviewCode(targetPath) {
return new Promise((resolve, reject) => {
const postData = JSON.stringify({ path: targetPath });
const options = {
hostname: HOST,
port: PORT,
path: '/api/code-review',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(postData)
},
timeout: 5000
};
const req = http.request(options, (res) => {
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
try {
const result = JSON.parse(data);
resolve(result);
} catch (error) {
reject(new Error(`解析响应失败: ${error.message}`));
}
});
});
req.on('error', (error) => {
reject(error);
});
req.on('timeout', () => {
req.destroy();
reject(new Error('请求超时'));
});
req.write(postData);
req.end();
});
}
function getAllComponents() {
const srcDir = path.resolve(__dirname, '../src');
const components = [];
// 扫描 prototypes
const pagesDir = path.join(srcDir, 'prototypes');
if (fs.existsSync(pagesDir)) {
const prototypes = fs.readdirSync(pagesDir, { withFileTypes: true });
prototypes.forEach(page => {
if (page.isDirectory()) {
const indexPath = path.join(pagesDir, page.name, 'index.tsx');
if (fs.existsSync(indexPath)) {
components.push({
type: 'page',
name: page.name,
path: `prototypes/${page.name}`
});
}
}
});
}
// 扫描 components
const elementsDir = path.join(srcDir, 'components');
if (fs.existsSync(elementsDir)) {
const components = fs.readdirSync(elementsDir, { withFileTypes: true });
components.forEach(element => {
if (element.isDirectory()) {
const indexPath = path.join(elementsDir, element.name, 'index.tsx');
if (fs.existsSync(indexPath)) {
components.push({
type: 'element',
name: element.name,
path: `components/${element.name}`
});
}
}
});
}
return components;
}
function formatIssue(issue, indent = ' ') {
const icon = issue.type === 'error' ? '❌' : '⚠️';
let output = `${indent}${icon} [${issue.rule}] ${issue.message}`;
if (issue.suggestion) {
output += `\n${indent} 💡 ${issue.suggestion}`;
}
return output;
}
async function main() {
console.log('\n🔍 扫描项目中的所有组件和页面...\n');
const components = getAllComponents();
if (components.length === 0) {
console.log('❌ 未找到任何组件或页面\n');
process.exit(1);
}
console.log(`找到 ${components.length} 个组件/页面\n`);
console.log('='.repeat(70));
let totalChecked = 0;
let totalPassed = 0;
let totalErrors = 0;
let totalWarnings = 0;
const failedComponents = [];
for (const component of components) {
totalChecked++;
const displayName = `${component.type === 'page' ? '📄' : '🧩'} ${component.name}`;
try {
const result = await reviewCode(component.path);
if (result.error) {
console.log(`\n${displayName}`);
console.log(` ❌ 检查失败: ${result.error}`);
continue;
}
const errors = result.issues.filter(i => i.type === 'error');
const warnings = result.issues.filter(i => i.type === 'warning');
totalErrors += errors.length;
totalWarnings += warnings.length;
if (result.passed) {
totalPassed++;
console.log(`\n${displayName}`);
console.log(` ✅ 通过`);
} else {
failedComponents.push({
component,
result
});
console.log(`\n${displayName}`);
console.log(` ❌ 未通过 (${errors.length} 错误, ${warnings.length} 警告)`);
// 只显示错误,不显示警告(简化输出)
if (errors.length > 0) {
errors.forEach(issue => {
console.log(formatIssue(issue));
});
}
}
} catch (error) {
console.log(`\n${displayName}`);
console.log(` ❌ 检查失败: ${error.message}`);
}
}
// 总结
console.log('\n' + '='.repeat(70));
console.log('\n📊 检查总结:\n');
console.log(` 总计: ${totalChecked} 个组件/页面`);
console.log(` 通过: ${totalPassed} 个 ✅`);
console.log(` 失败: ${totalChecked - totalPassed} 个 ❌`);
console.log(` 错误: ${totalErrors}`);
console.log(` 警告: ${totalWarnings}`);
if (failedComponents.length > 0) {
console.log('\n⚠ 需要修复的组件:\n');
failedComponents.forEach(({ component }) => {
console.log(` - ${component.path}`);
});
}
console.log('\n' + '='.repeat(70) + '\n');
if (totalPassed === totalChecked) {
console.log('✅ 所有组件都符合规范!\n');
} else {
console.log('⚠️ 部分组件需要修复,请查看上面的详细信息。\n');
console.log('💡 使用以下命令查看单个组件的详细问题:');
console.log(' node scripts/code-review.js <path>\n');
process.exit(1);
}
}
// 检查服务器
function checkServer() {
return new Promise((resolve) => {
const req = http.get(`http://${HOST}:${PORT}/api/version`, (res) => {
resolve(true);
});
req.on('error', () => {
resolve(false);
});
req.setTimeout(2000, () => {
req.destroy();
resolve(false);
});
});
}
(async () => {
const serverRunning = await checkServer();
if (!serverRunning) {
console.error('\n❌ 错误:开发服务器未运行');
console.log('\n请先启动开发服务器');
console.log(' npm run dev\n');
process.exit(1);
}
await main();
})();

View File

@@ -0,0 +1,18 @@
import path from 'path';
import { fileURLToPath } from 'url';
import { scanProjectEntries, writeEntriesManifestAtomic } from '../vite-plugins/utils/entriesManifestCore.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const projectRoot = path.resolve(__dirname, '..');
const manifest = scanProjectEntries(projectRoot, ['components', 'prototypes', 'themes']);
const written = writeEntriesManifestAtomic(projectRoot, manifest);
console.log(
'Generated entries.json (schema v2) with',
Object.keys(written.js || {}).length,
'js entries and',
Object.keys(written.html || {}).length,
'html entries (using unified template)',
);

View File

@@ -0,0 +1,77 @@
#!/usr/bin/env node
const baseUrl = process.argv[2] || 'http://localhost:51720';
const targets = process.argv.slice(3).length > 0
? process.argv.slice(3)
: [
'/prototypes/ref-antd-copy',
'/components/ref-button',
'/themes/antd-new',
];
let hasFailure = false;
for (const target of targets) {
const requestUrl = new URL(target, baseUrl).toString();
try {
const response = await fetch(requestUrl, {
redirect: 'follow',
headers: {
Accept: 'text/html',
},
});
const html = await response.text();
const htmlProxyMatches = Array.from(
html.matchAll(/src="([^"]*html-proxy[^"]*)"/g),
(match) => match[1],
);
const bootstrapProxy = htmlProxyMatches.find((value) => value.includes('index=0.js')) || null;
const hostProxy = htmlProxyMatches.find((value) => value.includes('index=1.js')) || null;
let bootstrapScript = '';
let hostScript = '';
if (bootstrapProxy) {
bootstrapScript = await fetch(new URL(bootstrapProxy, baseUrl)).then((res) => res.text());
}
if (hostProxy) {
hostScript = await fetch(new URL(hostProxy, baseUrl)).then((res) => res.text());
}
const ok = response.ok
&& html.includes('<div id="root"></div>')
&& htmlProxyMatches.length >= 2
&& !html.includes('waitForBootstrap')
&& bootstrapScript.includes('dev-template-bootstrap.js')
&& hostScript.includes('import PreviewComponent from')
&& hostScript.includes('import.meta.hot.accept(')
&& hostScript.includes(`window.AxhubDevComponent = CurrentComponent;`)
&& html.includes('<div id="root"></div>');
if (!ok) {
hasFailure = true;
console.error(`[preview-smoke] FAIL ${requestUrl}`);
console.error(` status=${response.status}`);
console.error(` containsRoot=${html.includes('<div id="root"></div>')}`);
console.error(` htmlProxyCount=${htmlProxyMatches.length}`);
console.error(` removedLegacyLoader=${!html.includes('waitForBootstrap')}`);
console.error(` bootstrapProxy=${Boolean(bootstrapProxy)}`);
console.error(` hostProxy=${Boolean(hostProxy)}`);
console.error(` hostImportsEntry=${hostScript.includes('import PreviewComponent from')}`);
console.error(` hostHasAcceptBoundary=${hostScript.includes('import.meta.hot.accept(')}`);
console.error(` hostSetsDebugGlobals=${hostScript.includes('window.AxhubDevComponent = CurrentComponent;')}`);
continue;
}
console.log(`[preview-smoke] OK ${requestUrl}`);
} catch (error) {
hasFailure = true;
console.error(`[preview-smoke] ERROR ${requestUrl}`);
console.error(` ${(error && error.message) || error}`);
}
}
if (hasFailure) {
process.exitCode = 1;
}

View File

@@ -0,0 +1,49 @@
#!/usr/bin/env node
const baseUrl = process.argv[2] || 'http://localhost:51720';
const rootUrl = new URL('/', baseUrl).toString();
const previewUrl = new URL('/prototypes/ref-antd-copy', baseUrl).toString();
let hasFailure = false;
async function checkHtml(url, expected) {
const response = await fetch(url, {
redirect: 'follow',
headers: { Accept: 'text/html' },
});
const html = await response.text();
const result = {
ok: response.ok,
hasViteClient: html.includes('/@vite/client'),
hasReactRefreshPreamble: html.includes('/@react-refresh'),
};
const matches = expected(result);
if (!matches) {
hasFailure = true;
console.error(`[shell-boundary] FAIL ${url}`);
console.error(` status=${response.status}`);
console.error(` hasViteClient=${result.hasViteClient}`);
console.error(` hasReactRefreshPreamble=${result.hasReactRefreshPreamble}`);
return;
}
console.log(`[shell-boundary] OK ${url}`);
}
await checkHtml(rootUrl, (result) => (
result.ok
&& !result.hasViteClient
&& !result.hasReactRefreshPreamble
));
await checkHtml(previewUrl, (result) => (
result.ok
&& result.hasViteClient
&& result.hasReactRefreshPreamble
));
if (hasFailure) {
process.exitCode = 1;
}

View File

@@ -0,0 +1,832 @@
#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { assertValidGeneratedTsx } from './utils/generatedTsxValidator.mjs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const CONFIG = {
projectRoot: path.resolve(__dirname, '..'),
pagesDir: path.resolve(__dirname, '../src/prototypes'),
pendingLogicFileName: '.stitch-pending.json',
pendingLogicMarker: 'STITCH_PENDING_LOGIC',
};
function log(message, type = 'info') {
const prefix = { info: '✓', warn: '⚠', error: '✗', progress: '⏳' }[type] || '';
console.log(`${prefix} ${message}`);
}
function ensureDir(dirPath) {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
}
function sanitizePageName(name) {
return String(name)
.replace(/[^a-z0-9-_]/gi, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
.toLowerCase();
}
function escapeForTemplateLiteral(value) {
return String(value)
.replace(/\\/g, '\\\\')
.replace(/`/g, '\\`')
.replace(/\$\{/g, '\\${');
}
function extractTailwindConfig(html) {
const startMatch = html.match(/tailwind\.config\s*=\s*\{/);
if (!startMatch) return null;
const startIndex = startMatch.index + startMatch[0].length - 1;
let braceCount = 0;
let endIndex = startIndex;
for (let i = startIndex; i < html.length; i += 1) {
if (html[i] === '{') braceCount += 1;
else if (html[i] === '}') {
braceCount -= 1;
if (braceCount === 0) {
endIndex = i + 1;
break;
}
}
}
if (braceCount !== 0) return null;
const configStr = html.substring(startIndex, endIndex);
try {
let cleanedStr = configStr
.split('\n')
.map((line) => {
const commentIndex = line.indexOf('//');
if (commentIndex >= 0) {
return line.substring(0, commentIndex).trimEnd();
}
return line;
})
.join('\n');
cleanedStr = cleanedStr.replace(/,(\s*[}\]])/g, '$1');
return (function () {
return eval(`(${cleanedStr})`);
}());
} catch (error) {
console.error('[Stitch Converter] 解析 Tailwind 配置失败:', error.message);
return null;
}
}
function generateTailwindCSS(config) {
if (!config || !config.theme) return '';
const theme = config.theme;
const extend = theme.extend || {};
let css = '';
const processColors = (colors, prefix = '') => {
let colorCSS = '';
for (const [name, value] of Object.entries(colors)) {
if (typeof value === 'object' && !Array.isArray(value)) {
colorCSS += processColors(value, `${prefix}${name}-`);
} else {
const cssName = `${prefix}${name}`.replace(/([A-Z])/g, '-$1').toLowerCase();
colorCSS += ` --color-${cssName}: ${value};\n`;
}
}
return colorCSS;
};
if (extend.colors) {
css += '\n@theme {\n';
css += processColors(extend.colors);
css += '}\n';
}
if (extend.spacing) {
css += '\n@theme {\n';
for (const [name, value] of Object.entries(extend.spacing)) {
css += ` --spacing-${name}: ${value};\n`;
}
css += '}\n';
}
if (extend.fontSize) {
css += '\n@theme {\n';
for (const [name, value] of Object.entries(extend.fontSize)) {
const size = Array.isArray(value) ? value[0] : value;
css += ` --font-size-${name}: ${size};\n`;
if (Array.isArray(value) && value[1]) {
const lineHeight = typeof value[1] === 'object' ? value[1].lineHeight : value[1];
if (lineHeight) {
css += ` --line-height-${name}: ${lineHeight};\n`;
}
}
}
css += '}\n';
}
if (extend.fontFamily) {
css += '\n@theme {\n';
for (const [name, value] of Object.entries(extend.fontFamily)) {
const family = Array.isArray(value) ? value.join(', ') : value;
css += ` --font-family-${name}: ${family};\n`;
}
css += '}\n';
}
if (extend.borderRadius) {
css += '\n@theme {\n';
for (const [name, value] of Object.entries(extend.borderRadius)) {
const cssName = name === 'DEFAULT' ? 'default' : name;
css += ` --radius-${cssName}: ${value};\n`;
}
css += '}\n';
}
if (extend.boxShadow) {
css += '\n@theme {\n';
for (const [name, value] of Object.entries(extend.boxShadow)) {
const cssName = name === 'DEFAULT' ? 'default' : name;
css += ` --shadow-${cssName}: ${value};\n`;
}
css += '}\n';
}
if (extend.screens) {
css += '\n/* 自定义断点 */\n';
for (const [name, value] of Object.entries(extend.screens)) {
css += `/* @screen ${name}: ${value} */\n`;
}
}
if (extend.zIndex) {
css += '\n@theme {\n';
for (const [name, value] of Object.entries(extend.zIndex)) {
css += ` --z-index-${name}: ${value};\n`;
}
css += '}\n';
}
if (extend.opacity) {
css += '\n@theme {\n';
for (const [name, value] of Object.entries(extend.opacity)) {
css += ` --opacity-${name}: ${value};\n`;
}
css += '}\n';
}
if (extend.keyframes) {
css += '\n/* 动画关键帧 */\n';
for (const [name, frames] of Object.entries(extend.keyframes)) {
css += `@keyframes ${name} {\n`;
for (const [percent, styles] of Object.entries(frames)) {
css += ` ${percent} {\n`;
for (const [prop, val] of Object.entries(styles)) {
const cssProp = prop.replace(/([A-Z])/g, '-$1').toLowerCase();
css += ` ${cssProp}: ${val};\n`;
}
css += ' }\n';
}
css += '}\n\n';
}
}
if (extend.animation) {
css += '\n/* 动画工具类 */\n';
css += '@layer utilities {\n';
for (const [name, value] of Object.entries(extend.animation)) {
css += ` .animate-${name} {\n`;
css += ` animation: ${value};\n`;
css += ' }\n';
}
css += '}\n\n';
}
if (extend.transitionDuration) {
css += '\n@theme {\n';
for (const [name, value] of Object.entries(extend.transitionDuration)) {
css += ` --duration-${name}: ${value};\n`;
}
css += '}\n';
}
if (extend.transitionTimingFunction) {
css += '\n@theme {\n';
for (const [name, value] of Object.entries(extend.transitionTimingFunction)) {
css += ` --ease-${name}: ${value};\n`;
}
css += '}\n';
}
if (extend.backgroundImage) {
css += '\n@theme {\n';
for (const [name, value] of Object.entries(extend.backgroundImage)) {
css += ` --gradient-${name}: ${value};\n`;
}
css += '}\n';
}
if (config.darkMode) {
css += `\n/* Dark Mode: ${config.darkMode} */\n`;
}
return css;
}
function isTailwindConfigScript(attrs, content) {
return /tailwind\.config/.test(content) || /id=["']tailwind-config["']/i.test(attrs);
}
function summarizeScript(script) {
const source = script.content || script.src || '';
const normalized = source.toLowerCase();
if (/setinterval|settimeout/.test(normalized) && /time|date|clock/.test(normalized)) {
return '更新时间或日期显示';
}
if (/addEventListener\s*\(\s*['"]click['"]/.test(source) || /\.onclick\s*=/.test(source)) {
return '点击交互处理';
}
if (/addEventListener\s*\(\s*['"]scroll['"]/.test(source)) {
return '滚动相关行为';
}
if (/querySelector|getElementById|textContent|innerHTML/.test(source)) {
return '页面内容或状态更新';
}
if (script.src) {
return `外部脚本:${script.src}`;
}
return '页面动态逻辑';
}
function suggestMigration(script) {
const source = script.content || script.src || '';
const normalized = source.toLowerCase();
if (/setinterval|settimeout/.test(normalized)) {
return '迁移到 React.useEffect并在需要时配合 useState 管理显示内容。';
}
if (/addEventListener|onclick|onsubmit|onchange/.test(normalized)) {
return '迁移到组件事件处理函数或 React.useEffect 的事件绑定。';
}
if (/querySelector|getElementById|textContent|innerHTML/.test(source)) {
return '改为通过组件状态、props 或 ref 更新界面,避免直接操作 DOM。';
}
if (script.src) {
return '确认该脚本的用途后,再决定是否以 npm 依赖、按需加载或替代实现接入。';
}
return '建议拆解为组件状态、效果函数或独立 helper 后接入。';
}
function createPendingScript(script, index) {
return {
id: script.id || `${script.location}-script-${index + 1}`,
location: script.location,
src: script.src || null,
summary: summarizeScript(script),
suggestedMigration: suggestMigration(script),
code: script.content || '',
};
}
function extractHeadContent(html) {
const headMatch = html.match(/<head[^>]*>([\s\S]*?)<\/head>/i);
if (!headMatch) return { links: [], styles: [], pendingScripts: [] };
const headContent = headMatch[1];
const links = [];
const styles = [];
const pendingScripts = [];
const scriptRegex = /<script([^>]*)>([\s\S]*?)<\/script>/gi;
let match;
while ((match = scriptRegex.exec(headContent)) !== null) {
const attrs = match[1];
const content = match[2].trim();
const srcMatch = attrs.match(/src=["']([^"']+)["']/i);
if (srcMatch) {
const src = srcMatch[1].replace(/&amp;/g, '&');
if (src.includes('tailwindcss.com')) continue;
pendingScripts.push(createPendingScript({
location: 'head',
id: attrs.match(/id=["']([^"']+)["']/i)?.[1] || null,
src,
content: '',
}, pendingScripts.length));
continue;
}
if (!content || isTailwindConfigScript(attrs, content)) {
continue;
}
pendingScripts.push(createPendingScript({
location: 'head',
id: attrs.match(/id=["']([^"']+)["']/i)?.[1] || null,
src: null,
content,
}, pendingScripts.length));
}
const linkRegex = /<link[^>]*>/gi;
while ((match = linkRegex.exec(headContent)) !== null) {
const tag = match[0];
const href = tag.match(/href=["']([^"']+)["']/i)?.[1];
if (href) {
links.push({
href: href.replace(/&amp;/g, '&'),
rel: tag.match(/rel=["']([^"']+)["']/i)?.[1] || 'stylesheet',
crossorigin: tag.includes('crossorigin'),
});
}
}
const styleRegex = /<style[^>]*>([\s\S]*?)<\/style>/gi;
while ((match = styleRegex.exec(headContent)) !== null) {
const content = match[1].trim();
if (content) {
styles.push(content);
}
}
return { links, styles, pendingScripts };
}
function convertStyleToJSX(styleStr) {
if (!styleStr.trim()) return 'style={{}}';
const styles = [];
let currentProp = '';
let inUrl = false;
for (let i = 0; i < styleStr.length; i += 1) {
const char = styleStr[i];
if (char === '(' && styleStr.substring(i - 3, i) === 'url') inUrl = true;
else if (char === ')' && inUrl) inUrl = false;
if (char === ';' && !inUrl) {
if (currentProp.trim()) styles.push(currentProp.trim());
currentProp = '';
} else {
currentProp += char;
}
}
if (currentProp.trim()) styles.push(currentProp.trim());
const jsxStyles = styles
.filter((item) => item.includes(':'))
.map((item) => {
const colonIndex = item.indexOf(':');
const key = item.substring(0, colonIndex).trim();
const value = item.substring(colonIndex + 1).trim();
if (!key || !value) return '';
const camelKey = key.replace(/-([a-z])/g, (group) => group[1].toUpperCase());
let jsxValue;
if (value.startsWith('url(') || value.includes('var(')) {
jsxValue = `'${value.replace(/'/g, "\\'")}'`;
} else if (/^-?\d+(\.\d+)?$/.test(value)) {
jsxValue = value;
} else {
jsxValue = `'${value.replace(/'/g, "\\'")}'`;
}
return `${camelKey}: ${jsxValue}`;
})
.filter(Boolean)
.join(', ');
return `style={{ ${jsxStyles} }}`;
}
function convertCommonAttributesToJSX(content) {
const replacements = [
['class', 'className'],
['for', 'htmlFor'],
['tabindex', 'tabIndex'],
['readonly', 'readOnly'],
['maxlength', 'maxLength'],
['minlength', 'minLength'],
['colspan', 'colSpan'],
['rowspan', 'rowSpan'],
['viewbox', 'viewBox'],
['preserveaspectratio', 'preserveAspectRatio'],
['clip-path', 'clipPath'],
['fill-rule', 'fillRule'],
['clip-rule', 'clipRule'],
['stroke-width', 'strokeWidth'],
['stroke-dasharray', 'strokeDasharray'],
['stroke-dashoffset', 'strokeDashoffset'],
['stroke-linecap', 'strokeLinecap'],
['stroke-linejoin', 'strokeLinejoin'],
['stroke-miterlimit', 'strokeMiterlimit'],
['stroke-opacity', 'strokeOpacity'],
['fill-opacity', 'fillOpacity'],
['stop-color', 'stopColor'],
['stop-opacity', 'stopOpacity'],
['xlink:href', 'xlinkHref'],
['xmlns:xlink', 'xmlnsXlink'],
];
let nextContent = content;
replacements.forEach(([from, to]) => {
nextContent = nextContent.replace(new RegExp(`(\\s)${from}=`, 'gi'), `$1${to}=`);
});
return nextContent;
}
function convertHtmlToJsx(content) {
return convertCommonAttributesToJSX(content)
.replace(/<!--([\s\S]*?)-->/g, '{/* $1 */}')
.replace(/(<pre[^>]*>)([\s\S]*?)(<\/pre>)/gi, (match, openTag, preContent) => {
const escapedContent = preContent
.replace(/\\/g, '\\\\')
.replace(/`/g, '\\`')
.replace(/\$/g, '\\$')
.replace(/\{/g, '\\{');
return `${openTag.slice(0, -1)} dangerouslySetInnerHTML={{ __html: \`${escapedContent}\` }} />`;
})
.replace(/style='([^']*)'/gi, (match, styleStr) => convertStyleToJSX(styleStr))
.replace(/style="([^"]*)"/gi, (match, styleStr) => convertStyleToJSX(styleStr));
}
function extractBodyContent(html) {
const bodyMatch = html.match(/(<body[^>]*>)([\s\S]*?)<\/body>/i);
if (!bodyMatch) {
return {
wrapperOpenTag: '<div data-stitch-root="true">',
jsxContent: '',
pendingScripts: [],
};
}
const openTag = convertHtmlToJsx(bodyMatch[1])
.replace(/^<body/i, '<div data-stitch-root="true"');
const innerContent = bodyMatch[2];
const pendingScripts = [];
const contentWithoutScripts = innerContent.replace(/<script([^>]*)>([\s\S]*?)<\/script>/gi, (match, attrs, content) => {
const trimmedContent = String(content || '').trim();
pendingScripts.push(createPendingScript({
location: 'body',
id: attrs.match(/id=["']([^"']+)["']/i)?.[1] || attrs.match(/data-purpose=["']([^"']+)["']/i)?.[1] || null,
src: attrs.match(/src=["']([^"']+)["']/i)?.[1]?.replace(/&amp;/g, '&') || null,
content: trimmedContent,
}, pendingScripts.length));
return '';
});
return {
wrapperOpenTag: openTag,
jsxContent: convertHtmlToJsx(contentWithoutScripts.trim()),
pendingScripts,
};
}
function buildPendingSummaryLines(pendingScripts) {
return pendingScripts.map((item) => ` // - [${item.location}] ${item.id}: ${item.summary}`);
}
function buildPendingEffectBlock(pendingScripts) {
if (pendingScripts.length === 0) return '';
const summaryLines = buildPendingSummaryLines(pendingScripts).join('\n');
return `
React.useEffect(function () {
// ${CONFIG.pendingLogicMarker}_START
// 后续可在这里继续完善页面的动态细节。
// 参考同目录下的 ${CONFIG.pendingLogicFileName}
${summaryLines}
// ${CONFIG.pendingLogicMarker}_END
}, []);
`;
}
function buildFriendlyNotice() {
return `
<div
className="pointer-events-none fixed right-4 top-4 z-[100] max-w-xs rounded-2xl border border-white/60 bg-white/88 px-4 py-3 text-sm text-slate-700 shadow-lg backdrop-blur"
style={{ boxShadow: '0 18px 45px rgba(15, 23, 42, 0.12)' }}
>
<div className="font-semibold text-slate-900">基础效果已就绪</div>
<div className="mt-1 leading-6 text-slate-600">
当前可先查看页面结构与样式效果,部分动态细节可在后续继续完善。
</div>
</div>`;
}
function generateComponent(pageName, wrapperOpenTag, bodyJsxContent, headContent, pendingScripts) {
const componentName = pageName
.split(/[-_\s]+/)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join('');
const injectionBlocks = [];
if (headContent.links.length > 0) {
injectionBlocks.push(`
React.useEffect(function () {
const injected = [];
${JSON.stringify(headContent.links)}.forEach(function (linkInfo) {
const existing = document.querySelector(\`link[href="\${linkInfo.href}"]\`);
if (!existing) {
const link = document.createElement('link');
link.rel = linkInfo.rel;
link.href = linkInfo.href;
if (linkInfo.crossorigin) link.crossOrigin = 'anonymous';
document.head.appendChild(link);
injected.push(link);
}
});
return function () {
injected.forEach(function (el) {
if (el.parentNode) el.parentNode.removeChild(el);
});
};
}, []);
`);
}
if (pendingScripts.length > 0) {
injectionBlocks.push(buildPendingEffectBlock(pendingScripts));
}
const friendlyNotice = pendingScripts.length > 0 ? buildFriendlyNotice() : '';
const wrapperJsx = `${wrapperOpenTag}
${friendlyNotice}
${bodyJsxContent}
</div>`;
return `/**
* @name ${pageName}
*
* 参考资料:
* - /rules/development-guide.md
* - /skills/default-resource-recommendations/SKILL.md
*/
import './style.css';
import React, { forwardRef, useImperativeHandle } from 'react';
import type { AxureProps, AxureHandle } from '../../common/axure-types';
const Component = forwardRef(function ${componentName}(
_props: AxureProps,
ref: React.ForwardedRef<AxureHandle>,
) {
useImperativeHandle(ref, function () {
return {
getVar: function () { return undefined; },
fireAction: function () {},
eventList: [],
actionList: [],
varList: [],
configList: [],
dataList: []
};
}, []);
${injectionBlocks.join('')}
return (
${wrapperJsx.split('\n').map((line) => ` ${line}`).join('\n')}
);
});
export default Component;
`;
}
function generateStyleCSS(headContent, tailwindConfig) {
let css = '@import "tailwindcss";\n';
if (tailwindConfig) {
css += '\n/* 从 Stitch Tailwind 配置提取的样式 */';
css += generateTailwindCSS(tailwindConfig);
}
if (headContent.styles && headContent.styles.length > 0) {
css += '\n/* 原始自定义样式 */\n';
css += headContent.styles.join('\n\n');
}
return css;
}
function buildPendingLogicPayload(pageName, outputDir, pendingScripts) {
const relativeDir = path.relative(CONFIG.projectRoot, outputDir).split(path.sep).join('/');
const relativeIndexFile = `${relativeDir}/index.tsx`;
const relativePendingFile = `${relativeDir}/${CONFIG.pendingLogicFileName}`;
return {
pageName,
generatedAt: new Date().toISOString(),
marker: CONFIG.pendingLogicMarker,
indexFile: relativeIndexFile,
pendingFile: relativePendingFile,
items: pendingScripts,
};
}
function buildPrompt(pageName, outputDir, pendingPayload) {
const relativeDir = path.relative(CONFIG.projectRoot, outputDir).split(path.sep).join('/');
const relativeHtmlFile = `${relativeDir}/code.html`;
const summaries = pendingPayload.items
.map((item) => `- [${item.location}] ${item.id}: ${item.summary}。建议:${item.suggestedMigration}`)
.join('\n');
return `请继续完善 Stitch 导入后的页面“${pageName}”。
目标目录:
- \`${relativeDir}\`
请优先阅读以下文件:
- \`${relativeDir}/index.tsx\`
- \`${relativeDir}/${CONFIG.pendingLogicFileName}\`
- \`${relativeHtmlFile}\`
当前页面已经可以静态预览,但仍有待完善的动态细节:
${summaries}
请按以下方式处理:
1. 在 \`index.tsx\` 中的 \`${CONFIG.pendingLogicMarker}_START\` / \`${CONFIG.pendingLogicMarker}_END\` 区域附近补入需要的 React 逻辑。
2. 把原始脚本改写为 React 状态、effects、事件处理函数或必要的 helper。
3. 尽量不要直接操作 DOM优先使用 state、props、ref。
4. 完成逻辑迁移后,补充生成同目录下的 \`spec.md\`,说明页面结构、动态行为和实现要点。
5. 最后运行项目内的可用验收方式,确认页面可正常预览。
`;
}
function convertPage(pagePath, outputDir, pageName) {
log(`正在转换页面: ${pageName}`, 'progress');
const htmlPath = path.join(pagePath, 'code.html');
const html = fs.readFileSync(htmlPath, 'utf8');
const tailwindConfig = extractTailwindConfig(html);
const headContent = extractHeadContent(html);
const bodyContent = extractBodyContent(html);
const pendingScripts = [...headContent.pendingScripts, ...bodyContent.pendingScripts];
ensureDir(outputDir);
const componentCode = generateComponent(
pageName,
bodyContent.wrapperOpenTag,
bodyContent.jsxContent,
headContent,
pendingScripts,
);
const styleCSS = generateStyleCSS(headContent, tailwindConfig);
const outputTsxPath = path.join(outputDir, 'index.tsx');
assertValidGeneratedTsx(componentCode, outputTsxPath);
fs.writeFileSync(outputTsxPath, componentCode, 'utf8');
fs.writeFileSync(path.join(outputDir, 'style.css'), styleCSS, 'utf8');
const pendingPayload = buildPendingLogicPayload(pageName, outputDir, pendingScripts);
const pendingFilePath = path.join(outputDir, CONFIG.pendingLogicFileName);
if (pendingScripts.length > 0) {
fs.writeFileSync(pendingFilePath, `${JSON.stringify(pendingPayload, null, 2)}\n`, 'utf8');
log(` ✓ 已整理 ${pendingScripts.length} 段待完善逻辑`, 'info');
} else if (fs.existsSync(pendingFilePath)) {
fs.unlinkSync(pendingFilePath);
}
log(`页面转换完成: ${pageName}`, 'info');
return {
pageName,
outputDir,
pendingScripts,
pendingFile: pendingScripts.length > 0 ? pendingFilePath : null,
prompt: pendingScripts.length > 0 ? buildPrompt(pageName, outputDir, pendingPayload) : null,
};
}
function detectProjectType(stitchDir) {
const items = fs.readdirSync(stitchDir);
if (items.includes('code.html')) {
return { type: 'single', prototypes: [{ name: 'index', path: stitchDir }] };
}
const prototypes = [];
for (const item of items) {
const itemPath = path.join(stitchDir, item);
const stat = fs.statSync(itemPath);
if (stat.isDirectory() && fs.existsSync(path.join(itemPath, 'code.html'))) {
prototypes.push({ name: item, path: itemPath });
}
}
if (prototypes.length > 0) return { type: 'multi', prototypes };
throw new Error('未找到有效的 Stitch 项目结构');
}
function printResult(result) {
console.log(JSON.stringify(result));
}
async function main() {
const args = process.argv.slice(2);
if (args.length === 0 || args[0] === '--help') {
console.log(`
使用方法:
node scripts/stitch-converter.mjs <stitch-dir> [output-name]
`);
process.exit(0);
}
const stitchDirArg = args[0];
const outputName = sanitizePageName(args[1] || path.basename(stitchDirArg));
const stitchDir = path.resolve(CONFIG.projectRoot, stitchDirArg);
const outputDir = path.join(CONFIG.pagesDir, outputName);
if (!fs.existsSync(stitchDir)) {
log(`错误: 找不到目录 ${stitchDir}`, 'error');
process.exit(1);
}
try {
log('开始转换 Stitch 项目...', 'info');
const { type, prototypes } = detectProjectType(stitchDir);
log(`项目类型: ${type === 'single' ? '单页面' : '多页面'}`, 'info');
if (type === 'single') {
const pageResult = convertPage(prototypes[0].path, outputDir, outputName);
const result = {
success: true,
type,
mode: pageResult.pendingScripts.length > 0 ? 'ai_handoff' : 'safe_component',
pageName: outputName,
outputDir,
pendingLogicCount: pageResult.pendingScripts.length,
requiresAi: pageResult.pendingScripts.length > 0,
prompt: pageResult.prompt,
reasons: pageResult.pendingScripts.map((item) => `${item.summary}${item.location}`),
};
log('✅ 转换完成!', 'info');
printResult(result);
return;
}
const convertedPages = [];
for (const page of prototypes) {
const pageFolderName = sanitizePageName(`${outputName}-${page.name}`);
const pageOutputDir = path.join(CONFIG.pagesDir, pageFolderName);
const pageResult = convertPage(page.path, pageOutputDir, page.name);
convertedPages.push({
pageName: page.name,
folderName: pageFolderName,
outputDir: pageOutputDir,
pendingLogicCount: pageResult.pendingScripts.length,
requiresAi: pageResult.pendingScripts.length > 0,
prompt: pageResult.prompt,
reasons: pageResult.pendingScripts.map((item) => `${item.summary}${item.location}`),
});
}
log('✅ 转换完成!', 'info');
printResult({
success: true,
type,
mode: convertedPages.some((item) => item.requiresAi) ? 'ai_handoff' : 'safe_component',
pageName: outputName,
outputDir,
pages: convertedPages,
pendingLogicCount: convertedPages.reduce((sum, item) => sum + item.pendingLogicCount, 0),
requiresAi: convertedPages.some((item) => item.requiresAi),
prompt: convertedPages.find((item) => item.prompt)?.prompt || null,
reasons: convertedPages.flatMap((item) => item.reasons),
});
} catch (error) {
log(`转换失败: ${error.message}`, 'error');
process.exit(1);
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
main();
}
export {
generateComponent,
};

View File

@@ -0,0 +1,214 @@
#!/usr/bin/env node
/**
* switch-agent.mjs — Switch the active cc-connect agent.
*
* Usage:
* node scripts/switch-agent.mjs <agent> # Switch to agent (codex, claudecode, gemini)
* node scripts/switch-agent.mjs --status # Show current active agent
*
* Strategy:
* 1. Try HTTP API (dev server has write permissions to ~/.cc-connect/)
* 2. Fall back to direct file manipulation if HTTP fails
*/
import fs from 'fs';
import path from 'path';
import os from 'os';
import { execSync } from 'child_process';
const SUPPORTED_AGENTS = ['codex', 'claudecode', 'gemini'];
const ALIASES = { claude: 'claudecode', cc: 'claudecode', gem: 'gemini', cx: 'codex' };
// Read dev server info to get the port
function getDevServerPort() {
const candidates = [
path.join(process.cwd(), '.axhub', 'make', '.dev-server-info.json'),
path.resolve(import.meta.dirname, '..', '.axhub', 'make', '.dev-server-info.json'),
];
for (const p of candidates) {
try {
const info = JSON.parse(fs.readFileSync(p, 'utf8'));
if (info?.port) return info.port;
} catch { /* ignore */ }
}
return 51720; // default
}
// ---------------------------------------------------------------------------
// HTTP-based switching (preferred — dev server has write permissions)
// ---------------------------------------------------------------------------
async function httpSwitch(target) {
const port = getDevServerPort();
const url = `http://127.0.0.1:${port}/api/cc-connect/switch-agent`;
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ agent: target }),
signal: AbortSignal.timeout(15000),
});
const data = await res.json();
if (data.success) {
return { ok: true, detail: data.message || `已切换到 ${target}` };
}
return { ok: false, detail: data.error || '切换失败' };
}
async function httpStatus() {
const port = getDevServerPort();
const url = `http://127.0.0.1:${port}/api/cc-connect/active-agent`;
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
return await res.json();
}
// ---------------------------------------------------------------------------
// File-based switching (fallback — requires write permissions to ~/.cc-connect/)
// ---------------------------------------------------------------------------
const CC_CONNECT_DIR = path.join(os.homedir(), '.cc-connect');
const STATE_PATH = path.join(CC_CONNECT_DIR, 'axhub-weixin-state.json');
const CONFIG_PATH = path.join(CC_CONNECT_DIR, 'config.toml');
function readState() {
if (!fs.existsSync(STATE_PATH)) return null;
try {
const parsed = JSON.parse(fs.readFileSync(STATE_PATH, 'utf8'));
if (parsed?.version === 1 && parsed?.activeAgent && parsed?.weixinOptions?.token) {
return parsed;
}
} catch { /* ignore */ }
return null;
}
function escapeToml(value) {
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t');
}
function fileSwitch(target) {
const state = readState();
if (!state) {
return { ok: false, detail: '未找到微信绑定状态,请先在管理后台扫码绑定' };
}
if (state.activeAgent === target) {
return { ok: true, detail: `当前已是 ${target},无需切换` };
}
state.activeAgent = target;
fs.mkdirSync(CC_CONNECT_DIR, { recursive: true });
fs.writeFileSync(STATE_PATH, JSON.stringify(state, null, 2), 'utf8');
const optionLines = Object.entries(state.weixinOptions)
.map(([k, v]) => `${k} = "${escapeToml(String(v))}"`)
.join('\n');
const config = `# Auto-generated by Axhub Make
# language = "zh"
[log]
level = "info"
[[projects]]
name = "axhub-${escapeToml(target)}"
[projects.agent]
type = "${escapeToml(target)}"
[projects.agent.options]
work_dir = "${escapeToml(state.workDir)}"
mode = "yolo"
[[projects.platforms]]
type = "weixin"
[projects.platforms.options]
${optionLines}
`;
fs.writeFileSync(CONFIG_PATH, config, 'utf8');
const ccCommand = process.platform === 'win32' ? 'cc-connect.cmd' : 'cc-connect';
try { execSync(`${ccCommand} daemon stop`, { stdio: 'ignore', timeout: 10000 }); } catch { /* */ }
try { execSync(`${ccCommand} daemon uninstall`, { stdio: 'ignore', timeout: 10000 }); } catch { /* */ }
try {
execSync(`${ccCommand} daemon install --config "${CONFIG_PATH}"`, { stdio: 'inherit', timeout: 20000 });
return { ok: true, detail: `已切换到 ${target}` };
} catch (error) {
return { ok: false, detail: `配置已更新,但 daemon 重启失败: ${error.message}` };
}
}
function fileStatus() {
const state = readState();
if (state) {
return { activeAgent: state.activeAgent, configuredAgents: state.configuredAgents };
}
return null;
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
const args = process.argv.slice(2);
if (args.length === 0 || args[0] === '--help') {
console.log('用法: node scripts/switch-agent.mjs <agent>');
console.log(`可用: ${SUPPORTED_AGENTS.join(', ')}`);
console.log('别名: claude→claudecode, gem→gemini, cx→codex');
console.log(' node scripts/switch-agent.mjs --status');
process.exit(0);
}
if (args[0] === '--status') {
const data = fileStatus();
if (data) {
console.log(JSON.stringify(data, null, 2));
} else {
try {
const httpData = await httpStatus();
console.log(JSON.stringify(httpData, null, 2));
} catch {
console.log('未找到微信绑定状态');
}
}
process.exit(0);
}
let target = args[0].toLowerCase();
if (ALIASES[target]) target = ALIASES[target];
if (!SUPPORTED_AGENTS.includes(target)) {
console.error(`❌ 不支持的 agent: ${target}`);
console.error(`可用: ${SUPPORTED_AGENTS.join(', ')}`);
process.exit(1);
}
// Strategy: try file-based first, fall back to HTTP API
try {
const result = fileSwitch(target);
if (result.ok) {
console.log(`${result.detail}`);
process.exit(0);
} else {
console.error(`❌ 本地切换失败: ${result.detail}`);
console.log('尝试 HTTP API 切换...');
}
} catch (fileError) {
console.log(`本地文件操作受限 (${fileError.message}),尝试 HTTP API 切换...`);
}
// Fallback to HTTP API
try {
const result = await httpSwitch(target);
if (result.ok) {
console.log(`${result.detail}`);
} else {
console.error(`${result.detail}`);
process.exit(1);
}
} catch (httpError) {
console.error(`❌ HTTP API 也不可达: ${httpError.message}`);
console.error('请在管理后台的微信连接对话框中进行切换');
process.exit(1);
}

View File

@@ -0,0 +1,125 @@
import { mkdir, rm, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const projectRoot = path.resolve(__dirname, '..');
const LEGACY_DOC_SKILLS_SEGMENT = '/doc-skills/';
const LEGACY_DOC_SKILLS_DIR = path.resolve(projectRoot, 'skills/third-party/doc-skills');
const SKILL_SOURCES = [
{
target: 'skills/third-party/frontend-design/SKILL.md',
url: 'https://raw.githubusercontent.com/anthropics/skills/main/skills/frontend-design/SKILL.md',
},
{
target: 'skills/third-party/interface-design/SKILL.md',
url: 'https://raw.githubusercontent.com/Dammyjay93/interface-design/main/.claude/skills/interface-design/SKILL.md',
},
{
target: 'skills/ui-ux-pro-max/SKILL.md',
url: 'https://raw.githubusercontent.com/nextlevelbuilder/ui-ux-pro-max-skill/main/.claude/skills/ui-ux-pro-max/SKILL.md',
},
{
target: 'skills/third-party/implement-design/SKILL.md',
url: 'https://raw.githubusercontent.com/figma/mcp-server-guide/main/skills/implement-design/SKILL.md',
},
{
target: 'skills/third-party/baoyu-image-gen/SKILL.md',
url: 'https://raw.githubusercontent.com/jimliu/baoyu-skills/main/skills/baoyu-image-gen/SKILL.md',
},
{
target: 'skills/third-party/shadcn-ui/SKILL.md',
url: 'https://raw.githubusercontent.com/giuseppe-trisciuoglio/developer-kit/main/plugins/developer-kit-typescript/skills/shadcn-ui/SKILL.md',
},
{
target: 'skills/third-party/ant-design/SKILL.md',
url: 'https://raw.githubusercontent.com/ant-design/antd-skill/main/skills/ant-design/SKILL.md',
},
{
target: 'skills/third-party/stitch-skills/design-md/SKILL.md',
url: 'https://raw.githubusercontent.com/google-labs-code/stitch-skills/main/skills/design-md/SKILL.md',
},
{
target: 'skills/third-party/stitch-skills/react-components/SKILL.md',
url: 'https://raw.githubusercontent.com/google-labs-code/stitch-skills/main/skills/react-components/SKILL.md',
},
{
target: 'skills/third-party/stitch-skills/stitch-loop/SKILL.md',
url: 'https://raw.githubusercontent.com/google-labs-code/stitch-skills/main/skills/stitch-loop/SKILL.md',
},
{
target: 'skills/third-party/brainstorming/SKILL.md',
url: 'https://raw.githubusercontent.com/obra/superpowers/main/skills/brainstorming/SKILL.md',
},
{
target: 'skills/third-party/deep-research/SKILL.md',
url: 'https://raw.githubusercontent.com/199-biotechnologies/claude-deep-research-skill/main/SKILL.md',
},
{
target: 'skills/third-party/anything-to-notebooklm/SKILL.md',
url: 'https://raw.githubusercontent.com/joeseesun/anything-to-notebooklm/main/SKILL.md',
},
{
target: 'skills/third-party/prd/SKILL.md',
url: 'https://raw.githubusercontent.com/github/awesome-copilot/main/skills/prd/SKILL.md',
},
{
target: 'skills/third-party/product-requirements/SKILL.md',
url: 'https://raw.githubusercontent.com/stellarlinkco/myclaude/master/skills/product-requirements/SKILL.md',
},
{
target: 'skills/third-party/research/SKILL.md',
url: 'https://raw.githubusercontent.com/tavily-ai/skills/main/skills/tavily/research/SKILL.md',
},
{
target: 'skills/third-party/user-story-writing/SKILL.md',
url: 'https://raw.githubusercontent.com/aj-geddes/useful-ai-prompts/main/skills/user-story-writing/SKILL.md',
},
];
async function syncSkill({ target, url }) {
if (target.includes(LEGACY_DOC_SKILLS_SEGMENT)) {
throw new Error(`Legacy nested target is not allowed: ${target}`);
}
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status} ${response.statusText}`);
}
const content = await response.text();
const outputPath = path.resolve(projectRoot, target);
await mkdir(path.dirname(outputPath), { recursive: true });
await writeFile(outputPath, content, 'utf8');
}
async function main() {
let hasErrors = false;
await rm(LEGACY_DOC_SKILLS_DIR, { recursive: true, force: true });
for (const source of SKILL_SOURCES) {
try {
await syncSkill(source);
console.log(`[OK] ${source.target}`);
} catch (error) {
hasErrors = true;
const message = error instanceof Error ? error.message : String(error);
console.error(`[FAIL] ${source.target} - ${message}`);
}
}
if (hasErrors) {
process.exitCode = 1;
return;
}
console.log(`Synced ${SKILL_SOURCES.length} third-party skills.`);
}
await main();

View File

@@ -0,0 +1,593 @@
{
"command": "inspect",
"generatedAt": "2026-04-01T10:21:53.845Z",
"figPath": "/Users/jianzhoulin/rd/Axhub Runtime/apps/axhub-make/scripts/templates/empty-canvas.fig",
"archive": {
"prelude": "fig-make",
"version": 101,
"parts": 2
},
"sourceRoot": "src",
"summary": {
"totalCodeFiles": 63,
"pathCounts": {
"(root)": 2,
"components": 5,
"components/figma": 1,
"components/mockups": 5,
"components/ui": 48,
"guidelines": 1,
"styles": 1
},
"duplicateGroups": []
},
"entries": [
{
"nodeChangeIndex": 7,
"name": "App.tsx",
"codeFilePath": null,
"logicalPath": "App.tsx",
"sourceCodeSha1": "08dbcc7fa8dfe3f4d85267eaee09a1b86ce3ab3e",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 9,
"name": "accordion.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/accordion.tsx",
"sourceCodeSha1": "96e2ea4a76d985018b49b3f26c18bd060992ba1c",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 10,
"name": "alert-dialog.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/alert-dialog.tsx",
"sourceCodeSha1": "4499b93fee3f76e8a2cfee3e04ad9acc14e914f8",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 11,
"name": "alert.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/alert.tsx",
"sourceCodeSha1": "c4c43c93ca441a0cc96160fcbddc367b42bd0785",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 12,
"name": "aspect-ratio.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/aspect-ratio.tsx",
"sourceCodeSha1": "da69e8483774b6cb06c9f1109f6c5481846fa373",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 13,
"name": "avatar.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/avatar.tsx",
"sourceCodeSha1": "acca40a2a8a7b9e8b16d56e34722622875ce9279",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 14,
"name": "badge.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/badge.tsx",
"sourceCodeSha1": "2bed0297b71935fb53c36665a93713017b4efba0",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 15,
"name": "breadcrumb.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/breadcrumb.tsx",
"sourceCodeSha1": "2607a19685b58acec5df7cf79cf3f319860f10d0",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 16,
"name": "button.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/button.tsx",
"sourceCodeSha1": "8cc739da9862b0c46a941f2c2d3c04e88ed14f6e",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 17,
"name": "calendar.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/calendar.tsx",
"sourceCodeSha1": "dcf6f206c549acd3d3d93938af700040cf4736b3",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 18,
"name": "card.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/card.tsx",
"sourceCodeSha1": "368b7c30660e2357f967712258eb04a947d105e6",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 19,
"name": "carousel.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/carousel.tsx",
"sourceCodeSha1": "b18b2ad961ae8df8504cd08af7049da628cfcc84",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 20,
"name": "chart.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/chart.tsx",
"sourceCodeSha1": "e2fa2b6b71b5b989b07d655d0311b0b656370868",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 21,
"name": "checkbox.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/checkbox.tsx",
"sourceCodeSha1": "031623c953f24511c2b364736dc0c29bbd72b0da",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 22,
"name": "collapsible.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/collapsible.tsx",
"sourceCodeSha1": "c4a50eb76c7aa18863f1be0fcfb77a39c555dea5",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 23,
"name": "command.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/command.tsx",
"sourceCodeSha1": "e2bacd22ead0ead7c35ae4ea81cc063648be5551",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 24,
"name": "context-menu.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/context-menu.tsx",
"sourceCodeSha1": "dcc0f2c734146df4fdda300fb88fc4b778a8e943",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 25,
"name": "dialog.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/dialog.tsx",
"sourceCodeSha1": "d2e2d438b7ec8f4655cb363bff0b51c362403679",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 26,
"name": "drawer.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/drawer.tsx",
"sourceCodeSha1": "77956c0b7663cbe3de836e37395bc58bd441e662",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 27,
"name": "dropdown-menu.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/dropdown-menu.tsx",
"sourceCodeSha1": "f261713be8c0469a202d6571e667dced43443dc1",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 28,
"name": "form.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/form.tsx",
"sourceCodeSha1": "b35ebd81d98a9f3a1ab62fba289883715f1d1d21",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 29,
"name": "hover-card.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/hover-card.tsx",
"sourceCodeSha1": "b72c3f65947cd7593c1a89a354fa1d6c338f36e9",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 30,
"name": "input-otp.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/input-otp.tsx",
"sourceCodeSha1": "71c2917cc4fe8bb4c80b11323909cf710a93d0c6",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 31,
"name": "input.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/input.tsx",
"sourceCodeSha1": "e9f0dfef25dba4b04ad97609243106533bc078dc",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 32,
"name": "label.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/label.tsx",
"sourceCodeSha1": "8c069a29a97703512a9c930d5c4014476834cc96",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 33,
"name": "menubar.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/menubar.tsx",
"sourceCodeSha1": "ea4c2aa51da161eeb2298dfbc5e70308022214e7",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 34,
"name": "navigation-menu.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/navigation-menu.tsx",
"sourceCodeSha1": "0d7f19cd8946a82063e8d98d3709903326db0aad",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 35,
"name": "pagination.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/pagination.tsx",
"sourceCodeSha1": "a259b6b07136d542c95ba8bd37d9337376acf65b",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 36,
"name": "popover.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/popover.tsx",
"sourceCodeSha1": "762244296d854ac1ba4015cdb7fcbd9db5ef1754",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 37,
"name": "progress.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/progress.tsx",
"sourceCodeSha1": "1a41de261cc00b1eb94b36077854ad9ed3c18804",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 38,
"name": "radio-group.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/radio-group.tsx",
"sourceCodeSha1": "650cb3abc42c9a1664e27e7b47eb09b76c6a4e0d",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 39,
"name": "resizable.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/resizable.tsx",
"sourceCodeSha1": "588fb45baae2adbcec07d32a0e59476878621ca7",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 40,
"name": "scroll-area.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/scroll-area.tsx",
"sourceCodeSha1": "a86380f1aed911b34917005ba4f9949642910dbf",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 41,
"name": "select.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/select.tsx",
"sourceCodeSha1": "37fa9c4ceda7950664c385c9ef27ba1bbee47bde",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 42,
"name": "separator.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/separator.tsx",
"sourceCodeSha1": "979129951eb27258e96545382eaa9e37b9de1209",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 43,
"name": "sheet.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/sheet.tsx",
"sourceCodeSha1": "4f25f42b08ca91c04c4510cc16eb020a9c7f9587",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 44,
"name": "sidebar.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/sidebar.tsx",
"sourceCodeSha1": "f79dfb3dbeef2d211510e47f9ac70f5cf33f37cd",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 45,
"name": "skeleton.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/skeleton.tsx",
"sourceCodeSha1": "0662ecc7ab4b6c1746d91e009ef88d219005448d",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 46,
"name": "slider.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/slider.tsx",
"sourceCodeSha1": "23b80e2e9a0da874dcf78e56feb54346fe2bd0bb",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 47,
"name": "sonner.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/sonner.tsx",
"sourceCodeSha1": "72058ff9b056b79f72f79dfbbcf2db164a79e4ab",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 48,
"name": "switch.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/switch.tsx",
"sourceCodeSha1": "f83deec5e477bd7ca727eea401354d83ff22f6b5",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 49,
"name": "table.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/table.tsx",
"sourceCodeSha1": "ee1282461b1b247aa28520bb5cd6ce716ac1b468",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 50,
"name": "tabs.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/tabs.tsx",
"sourceCodeSha1": "40469be2dacfc3fc86909433bafd1d92bf7616d6",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 51,
"name": "textarea.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/textarea.tsx",
"sourceCodeSha1": "d608dfd62677493c7361908d510ef7d42a49c212",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 52,
"name": "toggle-group.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/toggle-group.tsx",
"sourceCodeSha1": "7b49c924ad02e9ffc94a0be4fab7f92caab54552",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 53,
"name": "toggle.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/toggle.tsx",
"sourceCodeSha1": "82d3925cf6ee63d4092d366e01ad478ad7d2f325",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 54,
"name": "tooltip.tsx",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/tooltip.tsx",
"sourceCodeSha1": "8e78e17fad045f62a35dabc021fb7477849ac93a",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 55,
"name": "use-mobile.ts",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/use-mobile.ts",
"sourceCodeSha1": "b1102c4af2fef644861e5df108eb5b133dc71805",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 56,
"name": "utils.ts",
"codeFilePath": "components/ui",
"logicalPath": "components/ui/utils.ts",
"sourceCodeSha1": "f095b349a6d6cbcbf7bcabdb2f51b3095e7403f0",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 57,
"name": "ImageWithFallback.tsx",
"codeFilePath": "components/figma",
"logicalPath": "components/figma/ImageWithFallback.tsx",
"sourceCodeSha1": "e93040f24666348383e937031a95b102570d13ec",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 58,
"name": "globals.css",
"codeFilePath": "styles",
"logicalPath": "styles/globals.css",
"sourceCodeSha1": "70abb71fd97a750fb4ba7c1b892f2b70d8d611d5",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 59,
"name": "Guidelines.md",
"codeFilePath": "guidelines",
"logicalPath": "guidelines/Guidelines.md",
"sourceCodeSha1": "11f6b0e620fa939a50999dada1539096198d9a3b",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 60,
"name": "Dashboard.tsx",
"codeFilePath": "components",
"logicalPath": "components/Dashboard.tsx",
"sourceCodeSha1": "9f8d3c852c0752919d2f1725be3c902902b20971",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 61,
"name": "AddExpense.tsx",
"codeFilePath": "components",
"logicalPath": "components/AddExpense.tsx",
"sourceCodeSha1": "dcc8ea2bcab75e0c954784d40e8cbfb2e2ee74b7",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 62,
"name": "Analytics.tsx",
"codeFilePath": "components",
"logicalPath": "components/Analytics.tsx",
"sourceCodeSha1": "961d99d46e2e7c2b475a77783a098bd48efc27b8",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 63,
"name": "SearchFilter.tsx",
"codeFilePath": "components",
"logicalPath": "components/SearchFilter.tsx",
"sourceCodeSha1": "7dd9d547b69d19e3390564e6387039bc067891af",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 64,
"name": "SettingsProfile.tsx",
"codeFilePath": "components",
"logicalPath": "components/SettingsProfile.tsx",
"sourceCodeSha1": "806456c50af5a3f7d302b75a21d9d784f59b4f7d",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 65,
"name": "Attributions.md",
"codeFilePath": null,
"logicalPath": "Attributions.md",
"sourceCodeSha1": "4f53c7de5eb7d707eea56961544b22433e1a86a3",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 67,
"name": "DashboardMockup.tsx",
"codeFilePath": "components/mockups",
"logicalPath": "components/mockups/DashboardMockup.tsx",
"sourceCodeSha1": "61b29d59129500144886e0cc403f491d49eac3e5",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 68,
"name": "AddExpenseMockup.tsx",
"codeFilePath": "components/mockups",
"logicalPath": "components/mockups/AddExpenseMockup.tsx",
"sourceCodeSha1": "e5d430c751a626680cf8bca5411234bb9dfd8adb",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 69,
"name": "AnalyticsMockup.tsx",
"codeFilePath": "components/mockups",
"logicalPath": "components/mockups/AnalyticsMockup.tsx",
"sourceCodeSha1": "f94a844fbe540becde9c6314c04867d13b94461c",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 70,
"name": "SearchMockup.tsx",
"codeFilePath": "components/mockups",
"logicalPath": "components/mockups/SearchMockup.tsx",
"sourceCodeSha1": "05442b617000ddd07d81961858d1bcd2b13c7509",
"isDuplicate": false,
"duplicateCount": 1
},
{
"nodeChangeIndex": 71,
"name": "SettingsMockup.tsx",
"codeFilePath": "components/mockups",
"logicalPath": "components/mockups/SettingsMockup.tsx",
"sourceCodeSha1": "7a92a34c001a2c6be42031acf36eb657ce3810de",
"isDuplicate": false,
"duplicateCount": 1
}
]
}

Binary file not shown.

View File

@@ -0,0 +1,63 @@
export type DecodeOutputOptions = {
platform?: NodeJS.Platform;
};
export type RunCommandOptions = {
command: string;
args?: string[];
cwd?: string;
env?: NodeJS.ProcessEnv;
timeoutMs?: number;
detached?: boolean;
capture?: boolean;
stdio?: any;
};
export type RunCommandResult = {
command: string;
args: string[];
spawnCommand: string;
spawnArgs: string[];
code: number | null;
signal: NodeJS.Signals | null;
stdoutBuffer: Buffer;
stderrBuffer: Buffer;
stdout: string;
stderr: string;
};
export type RunCommandSyncOptions = {
command: string;
args?: string[];
cwd?: string;
env?: NodeJS.ProcessEnv;
timeoutMs?: number;
maxBuffer?: number;
};
export type RunCommandSyncResult = {
command: string;
args: string[];
spawnCommand: string;
spawnArgs: string[];
status: number | null;
signal: NodeJS.Signals | null;
error: Error | null;
stdoutBuffer: Buffer;
stderrBuffer: Buffer;
stdout: string;
stderr: string;
};
export function decodeOutput(value: unknown, options?: DecodeOutputOptions): string;
export function runCommand(options: RunCommandOptions): Promise<RunCommandResult>;
export function runCommandSync(options: RunCommandSyncOptions): RunCommandSyncResult;
export function commandExists(command: string): boolean;
export function getPreferredNpmCommand(): string;
export function getPreferredNpxCommand(): string;
export function getSpawnCommandSpec(command: string, args?: string[], platform?: NodeJS.Platform): {
command: string;
args: string[];
windowsHide: boolean;
};
export function __resetWindowsCodePageCacheForTests(): void;

View File

@@ -0,0 +1,340 @@
import { spawn, spawnSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import iconv from 'iconv-lite';
const WINDOWS_CODEPAGE_TIMEOUT_MS = 1200;
let cachedWindowsCodePage = null;
function getPlatform(overridePlatform) {
return overridePlatform || process.platform;
}
function quoteForCmdExec(value) {
if (!value) return '""';
if (!/[\s"&^|<>]/.test(value)) return value;
const escaped = String(value)
.replace(/(\\*)"/g, '$1$1\\"')
.replace(/(\\+)$/g, '$1$1');
return `"${escaped}"`;
}
function buildWindowsCommandLine(command, args) {
return [command, ...args].map((part) => quoteForCmdExec(String(part))).join(' ');
}
function getEnvValue(env, key) {
if (!env) return undefined;
const direct = env[key];
if (typeof direct === 'string' && direct.length > 0) {
return direct;
}
const matchedKey = Object.keys(env).find((candidate) => candidate.toLowerCase() === key.toLowerCase());
if (!matchedKey) return undefined;
const value = env[matchedKey];
return typeof value === 'string' && value.length > 0 ? value : undefined;
}
function getWindowsPathExtList(env) {
const pathExt = getEnvValue(env, 'PATHEXT') || '.COM;.EXE;.BAT;.CMD';
return pathExt
.split(';')
.map((ext) => ext.trim())
.filter(Boolean)
.map((ext) => (ext.startsWith('.') ? ext.toLowerCase() : `.${ext.toLowerCase()}`));
}
function resolveWindowsCommand(command, env) {
if (!command || typeof command !== 'string') return command;
const trimmed = command.trim();
if (!trimmed) return trimmed;
const hasPathSeparator = /[\\/]/.test(trimmed);
const ext = path.extname(trimmed);
const pathExts = ext ? [''] : getWindowsPathExtList(env);
const candidateDirs = hasPathSeparator
? ['']
: (getEnvValue(env, 'PATH') || '')
.split(';')
.map((entry) => entry.trim())
.filter(Boolean);
const baseCandidates = hasPathSeparator ? [trimmed] : candidateDirs.map((dir) => path.join(dir, trimmed));
for (const baseCandidate of baseCandidates) {
const suffixes = ext ? [''] : pathExts;
for (const suffix of suffixes) {
const fullPath = suffix ? `${baseCandidate}${suffix}` : baseCandidate;
if (fs.existsSync(fullPath)) {
return fullPath;
}
}
}
return trimmed;
}
function shouldUseWindowsCmdWrapper(platform, command) {
if (platform !== 'win32') return false;
return /\.(cmd|bat)$/i.test(command) || !/\.(exe|com)$/i.test(command);
}
function getSpawnSpec(command, args, platform = process.platform, env = process.env) {
const resolvedCommand = platform === 'win32' ? resolveWindowsCommand(command, env) : command;
if (!shouldUseWindowsCmdWrapper(platform, resolvedCommand)) {
return {
command: resolvedCommand,
args,
windowsHide: platform === 'win32',
};
}
const commandLine = buildWindowsCommandLine(resolvedCommand, args);
return {
command: 'cmd.exe',
args: ['/d', '/s', '/c', commandLine],
windowsHide: true,
};
}
function mapCodePageToEncoding(codePage) {
if (codePage === 65001) return 'utf8';
if (codePage === 936) return 'gbk';
if (codePage === 54936) return 'gb18030';
return 'gb18030';
}
function parseWindowsCodePage(text) {
if (!text) return null;
const match = String(text).match(/(\d{3,5})/);
if (!match) return null;
const codePage = Number(match[1]);
return Number.isFinite(codePage) ? codePage : null;
}
function readWindowsCodePageSync() {
if (cachedWindowsCodePage !== null) {
return cachedWindowsCodePage;
}
try {
const result = spawnSync('cmd.exe', ['/d', '/s', '/c', 'chcp'], {
windowsHide: true,
encoding: 'utf8',
timeout: WINDOWS_CODEPAGE_TIMEOUT_MS,
});
const output = `${result.stdout || ''}\n${result.stderr || ''}`;
cachedWindowsCodePage = parseWindowsCodePage(output);
} catch {
cachedWindowsCodePage = null;
}
return cachedWindowsCodePage;
}
function toBuffer(value) {
if (!value) return Buffer.alloc(0);
if (Buffer.isBuffer(value)) return value;
if (typeof value === 'string') return Buffer.from(value);
if (value instanceof Uint8Array) return Buffer.from(value);
return Buffer.from(String(value));
}
export function decodeOutput(value, options = {}) {
if (value === null || value === undefined) return '';
if (typeof value === 'string') return value;
const platform = getPlatform(options.platform);
const buffer = toBuffer(value);
if (buffer.length === 0) return '';
try {
const strictUtf8 = new TextDecoder('utf-8', { fatal: true }).decode(buffer);
return strictUtf8;
} catch {
// Fall through to platform fallback decoder.
}
if (platform === 'win32') {
const activeCodePage = readWindowsCodePageSync();
const preferredEncoding = mapCodePageToEncoding(activeCodePage);
try {
return iconv.decode(buffer, preferredEncoding);
} catch {
// Fall through to generic fallback.
}
try {
return iconv.decode(buffer, 'gb18030');
} catch {
// Fall through to latin1 fallback.
}
}
try {
return buffer.toString('utf8');
} catch {
return buffer.toString('latin1');
}
}
export function runCommandSync(options) {
const {
command,
args = [],
cwd,
env,
timeoutMs,
maxBuffer,
} = options;
const platform = process.platform;
const mergedEnv = env ? { ...process.env, ...env } : process.env;
const spawnSpec = getSpawnSpec(command, args, platform, mergedEnv);
const result = spawnSync(spawnSpec.command, spawnSpec.args, {
cwd,
env: mergedEnv,
timeout: timeoutMs,
maxBuffer,
windowsHide: spawnSpec.windowsHide,
encoding: null,
});
const stdoutBuffer = toBuffer(result.stdout);
const stderrBuffer = toBuffer(result.stderr);
return {
command,
args,
spawnCommand: spawnSpec.command,
spawnArgs: spawnSpec.args,
status: typeof result.status === 'number' ? result.status : null,
signal: result.signal || null,
error: result.error || null,
stdoutBuffer,
stderrBuffer,
stdout: decodeOutput(stdoutBuffer, { platform }),
stderr: decodeOutput(stderrBuffer, { platform }),
};
}
export function runCommand(options) {
const {
command,
args = [],
cwd,
env,
timeoutMs,
detached = false,
capture = true,
stdio,
} = options;
return new Promise((resolve, reject) => {
const platform = process.platform;
const mergedEnv = env ? { ...process.env, ...env } : process.env;
const spawnSpec = getSpawnSpec(command, args, platform, mergedEnv);
const child = spawn(spawnSpec.command, spawnSpec.args, {
cwd,
env: mergedEnv,
detached,
stdio: stdio || (capture ? ['ignore', 'pipe', 'pipe'] : 'inherit'),
windowsHide: spawnSpec.windowsHide,
});
const stdoutChunks = [];
const stderrChunks = [];
if (capture && child.stdout) {
child.stdout.on('data', (chunk) => {
stdoutChunks.push(toBuffer(chunk));
});
}
if (capture && child.stderr) {
child.stderr.on('data', (chunk) => {
stderrChunks.push(toBuffer(chunk));
});
}
let timeoutId = null;
if (typeof timeoutMs === 'number' && timeoutMs > 0) {
timeoutId = setTimeout(() => {
child.kill('SIGTERM');
}, timeoutMs);
}
child.once('error', (error) => {
if (timeoutId) clearTimeout(timeoutId);
reject(error);
});
child.once('close', (code, signal) => {
if (timeoutId) clearTimeout(timeoutId);
const stdoutBuffer = Buffer.concat(stdoutChunks);
const stderrBuffer = Buffer.concat(stderrChunks);
resolve({
command,
args,
spawnCommand: spawnSpec.command,
spawnArgs: spawnSpec.args,
code: typeof code === 'number' ? code : null,
signal: signal || null,
stdoutBuffer,
stderrBuffer,
stdout: decodeOutput(stdoutBuffer, { platform }),
stderr: decodeOutput(stderrBuffer, { platform }),
});
});
});
}
export function commandExists(command) {
if (!command || typeof command !== 'string') {
return false;
}
const checker = process.platform === 'win32' ? 'where' : 'which';
const result = runCommandSync({
command: checker,
args: [command],
timeoutMs: 2000,
});
return result.status === 0;
}
export function getPreferredNpmCommand() {
if (process.platform !== 'win32') {
return 'npm';
}
return commandExists('npm.cmd') ? 'npm.cmd' : 'npm';
}
export function getPreferredNpxCommand() {
if (process.platform !== 'win32') {
return 'npx';
}
return commandExists('npx.cmd') ? 'npx.cmd' : 'npx';
}
export function getSpawnCommandSpec(command, args = [], platform = process.platform) {
return getSpawnSpec(command, args, platform);
}
export function __resetWindowsCodePageCacheForTests() {
cachedWindowsCodePage = null;
}

View File

@@ -0,0 +1,41 @@
import ts from 'typescript';
function formatDiagnostic(diagnostic) {
const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n');
if (!diagnostic.file || typeof diagnostic.start !== 'number') {
return message;
}
const { line, character } = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start);
const sourceLine = diagnostic.file.text.split(/\r?\n/u)[line] || '';
return `${diagnostic.file.fileName}:${line + 1}:${character + 1} - ${message}\n${sourceLine}`;
}
export function validateGeneratedTsx(source, filePath = 'generated.tsx') {
const result = ts.transpileModule(source, {
fileName: filePath,
reportDiagnostics: true,
compilerOptions: {
jsx: ts.JsxEmit.ReactJSX,
module: ts.ModuleKind.ESNext,
target: ts.ScriptTarget.ESNext,
},
});
const diagnostics = (result.diagnostics || []).filter(
(diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error,
);
return {
ok: diagnostics.length === 0,
diagnostics,
formatted: diagnostics.map(formatDiagnostic).join('\n\n'),
};
}
export function assertValidGeneratedTsx(source, filePath = 'generated.tsx') {
const validation = validateGeneratedTsx(source, filePath);
if (!validation.ok) {
throw new Error(`生成的 TSX 语法校验失败:\n${validation.formatted}`);
}
}

View File

@@ -0,0 +1,602 @@
#!/usr/bin/env node
/**
* V0 项目预处理器(最小化处理模式)
*
* 只做 100% 有把握的操作:
* 1. 完整复制项目
* 2. 分析项目结构
* 3. 生成任务文档
*
* 不做任何代码修改,全部留给 AI 处理
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const TARGET_TYPE_TO_SRC_DIR = {
prototypes: 'src/prototypes',
components: 'src/components',
themes: 'src/themes',
};
const THEME_SPLIT_SKILL_DOCS = [
'/skills/axure-prototype-workflow/theme-generation.md',
'/skills/axure-prototype-workflow/doc-generation.md',
'/skills/axure-prototype-workflow/data-generation.md',
'/skills/web-page-workflow/theme-generation.md',
'/skills/web-page-workflow/doc-generation.md',
'/skills/web-page-workflow/data-generation.md',
];
const CONFIG = {
projectRoot: path.resolve(__dirname, '..'),
tempDir: path.resolve(__dirname, '../temp'),
};
function log(message, type = 'info') {
const prefix = { info: '✓', warn: '⚠', error: '✗', progress: '⏳' }[type] || '';
console.log(`${prefix} ${message}`);
}
function ensureDir(dirPath) {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
}
function sanitizeName(rawName) {
return String(rawName || '')
.replace(/[^a-z0-9-]/gi, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
.toLowerCase();
}
function getTargetInfo(targetType, outputName) {
const srcDir = TARGET_TYPE_TO_SRC_DIR[targetType];
const outputBaseDir = path.resolve(CONFIG.projectRoot, srcDir);
const outputDir = path.join(outputBaseDir, outputName);
const relativeOutputDir = `${srcDir}/${outputName}`;
if (targetType === 'themes') {
return {
targetType,
srcDir,
outputBaseDir,
outputDir,
relativeOutputDir,
tasksFileName: '.v0-theme-tasks.md',
analysisFileName: '.v0-theme-analysis.json',
checkPath: `/themes/${outputName}`,
label: '主题',
};
}
return {
targetType,
srcDir,
outputBaseDir,
outputDir,
relativeOutputDir,
tasksFileName: '.v0-tasks.md',
analysisFileName: '.v0-analysis.json',
checkPath: `/${targetType}/${outputName}`,
label: targetType === 'components' ? '组件' : '页面',
};
}
// 递归查找所有 .tsx/.ts 文件
function findFiles(dir, extensions = ['.tsx', '.ts']) {
const results = [];
if (!fs.existsSync(dir)) return results;
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
// 跳过 node_modules 和 .next
if (entry.name === 'node_modules' || entry.name === '.next') continue;
results.push(...findFiles(fullPath, extensions));
} else {
const ext = path.extname(entry.name);
if (extensions.includes(ext)) {
results.push(fullPath);
}
}
}
return results;
}
function copyDirectory(src, dest) {
if (!fs.existsSync(src)) return 0;
ensureDir(dest);
const entries = fs.readdirSync(src, { withFileTypes: true });
let count = 0;
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
if (entry.name === 'node_modules' || entry.name === '.next') continue;
count += copyDirectory(srcPath, destPath);
} else {
fs.copyFileSync(srcPath, destPath);
count++;
}
}
return count;
}
console.log('V0 Converter - Minimal Processing Mode\n');
/**
* 批量处理文件:删除 "use client" 和转换路径别名
* 这些是 100% 确定需要做的转换
*/
function processFiles(targetDir) {
const files = findFiles(targetDir, ['.tsx', '.ts', '.jsx', '.js']);
let processedCount = 0;
files.forEach(file => {
let content = fs.readFileSync(file, 'utf8');
let modified = false;
// 1. 删除 "use client" 指令100% 确定需要删除)
const newContent1 = content.replace(/['"]use client['"]\s*;?\s*\n?/g, '');
if (newContent1 !== content) {
content = newContent1;
modified = true;
}
// 2. 转换路径别名 @/ 为相对路径100% 确定需要转换)
if (content.includes('@/')) {
const fileDir = path.dirname(file);
const relativePath = path.relative(fileDir, targetDir);
// 替换 from '@/...' 和 from "@/..."
content = content.replace(
/from\s+(['"])@\//g,
`from $1${relativePath}/`
);
// 替换 import type ... from '@/...' 和 import type ... from "@/..."
content = content.replace(
/import\s+type\s+(.*from\s+)(['"])@\//g,
`import type $1$2${relativePath}/`
);
modified = true;
}
if (modified) {
fs.writeFileSync(file, content);
processedCount++;
}
});
return processedCount;
}
function analyzeProject(targetDir) {
const analysis = { files: [], pathAliases: [], nextjsImports: [], dependencies: {}, structure: {} };
const files = findFiles(targetDir, ['.tsx', '.ts']);
files.forEach(file => {
const relativePath = path.relative(targetDir, file);
const content = fs.readFileSync(file, 'utf8');
const fileInfo = {
path: relativePath,
hasUseClient: content.includes('"use client"') || content.includes("'use client'"),
pathAliases: [],
nextjsImports: []
};
const aliasMatches = content.matchAll(/from\s+['"]@\/([^'"]+)['"]/g);
for (const match of aliasMatches) {
fileInfo.pathAliases.push({
original: `@/${match[1]}`,
relative: path.relative(path.dirname(file), path.join(targetDir, match[1]))
});
}
const nextImports = content.matchAll(/import\s+.*from\s+['"]next\/([^'"]+)['"]/g);
for (const match of nextImports) {
fileInfo.nextjsImports.push(`next/${match[1]}`);
}
const vercelImports = content.matchAll(/import\s+.*from\s+['"]@vercel\/([^'"]+)['"]/g);
for (const match of vercelImports) {
fileInfo.nextjsImports.push(`@vercel/${match[1]}`);
}
analysis.files.push(fileInfo);
if (fileInfo.pathAliases.length > 0) {
analysis.pathAliases.push(...fileInfo.pathAliases.map(a => ({ file: relativePath, ...a })));
}
if (fileInfo.nextjsImports.length > 0) {
analysis.nextjsImports.push(...fileInfo.nextjsImports.map(imp => ({ file: relativePath, import: imp })));
}
});
const packageJsonPath = path.join(targetDir, 'package.json');
if (fs.existsSync(packageJsonPath)) {
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
const deps = packageJson.dependencies || {};
analysis.dependencies = {
all: deps,
toInstall: Object.keys(deps).filter(dep => {
if (dep === 'next' || dep.startsWith('next-')) return false;
if (dep.startsWith('@vercel/')) return false;
if (dep === 'react' || dep === 'react-dom') return false;
return true;
}),
excluded: Object.keys(deps).filter(dep => {
if (dep === 'next' || dep.startsWith('next-')) return true;
if (dep.startsWith('@vercel/')) return true;
if (dep === 'react' || dep === 'react-dom') return true;
return false;
})
};
}
analysis.structure = {
hasAppDir: fs.existsSync(path.join(targetDir, 'app')),
hasPageTsx: fs.existsSync(path.join(targetDir, 'app/page.tsx')),
hasLayoutTsx: fs.existsSync(path.join(targetDir, 'app/layout.tsx')),
hasGlobalsCss: fs.existsSync(path.join(targetDir, 'app/globals.css')),
hasComponentsDir: fs.existsSync(path.join(targetDir, 'components')),
hasHooksDir: fs.existsSync(path.join(targetDir, 'hooks')),
hasLibDir: fs.existsSync(path.join(targetDir, 'lib')),
hasPublicDir: fs.existsSync(path.join(targetDir, 'public'))
};
return analysis;
}
function buildAnalysisReport(analysis) {
return {
summary: {
totalFiles: analysis.files.length,
filesWithUseClient: analysis.files.filter(f => f.hasUseClient).length,
pathAliasCount: analysis.pathAliases.length,
nextjsImportCount: analysis.nextjsImports.length,
dependenciesToInstall: analysis.dependencies.toInstall?.length || 0
},
structure: analysis.structure,
pathAliases: analysis.pathAliases,
nextjsImports: analysis.nextjsImports,
dependencies: analysis.dependencies,
files: analysis.files
};
}
function generateDefaultTasksDocument(report, targetInfo, outputName, tempDir) {
const reportPath = path.join(targetInfo.outputDir, targetInfo.analysisFileName);
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
let markdown = `# V0 项目转换任务清单\n\n`;
markdown += `> **重要**: 请先阅读 \`/skills/v0-project-converter/SKILL.md\` 了解转换规范\n\n`;
markdown += `**名称**: ${outputName}\n`;
markdown += `**项目位置**: \`${targetInfo.relativeOutputDir}/\`\n`;
markdown += `**原始文件**: \`${tempDir}\` (仅供参考,不要修改)\n`;
markdown += `**生成时间**: ${new Date().toLocaleString()}\n\n`;
markdown += `## 📊 项目概况\n\n`;
markdown += `- 总文件数: ${report.summary.totalFiles}\n`;
markdown += `- ~~包含 'use client': ${report.summary.filesWithUseClient} 个文件~~ ✓ 已由脚本删除\n`;
markdown += `- ~~路径别名 (@/): ${report.summary.pathAliasCount} 处~~ ✓ 已由脚本转换\n`;
markdown += `- Next.js imports: ${report.summary.nextjsImportCount} 处(需要处理)\n`;
markdown += `- 需要安装的依赖: ${report.summary.dependenciesToInstall}\n\n`;
markdown += `## ✅ 转换任务\n\n`;
markdown += `### 任务 1: 创建 index.tsx\n\n`;
markdown += `**目标**: 将 \`app/page.tsx\` 包装为本项目组件\n\n`;
markdown += `**参考文件**: \`${targetInfo.relativeOutputDir}/app/page.tsx\`\n\n`;
markdown += `**操作**: 按照 \`/skills/v0-project-converter/SKILL.md\` 中的本项目组件规范创建 \`index.tsx\`\n\n`;
markdown += `### 任务 2: 创建 style.css\n\n`;
markdown += `**目标**: 基于 \`app/globals.css\` 创建样式文件\n\n`;
if (report.structure.hasGlobalsCss) {
markdown += `**参考文件**: \`${targetInfo.relativeOutputDir}/app/globals.css\`\n\n`;
markdown += `**操作**: 复制内容,确保开头有 \`@import "tailwindcss";\`\n\n`;
} else {
markdown += `**操作**: 创建基础样式文件,内容为 \`@import "tailwindcss";\`\n\n`;
}
markdown += `### 任务 3: 清理 Next.js 代码\n\n`;
markdown += `**目标**: 移除 Next.js 特定的 imports 和组件\n\n`;
markdown += `**需要处理**:\n`;
markdown += `- ~~删除 \`"use client"\` 指令~~ ✓ 已由脚本处理\n`;
markdown += `- ~~转换路径别名 \`@/\`~~ ✓ 已由脚本处理\n`;
markdown += `- 移除 Next.js imports (${report.nextjsImports.length} 处)\n`;
markdown += `- 替换组件: \`<Image>\`\`<img>\`, \`<Link>\`\`<a>\`\n`;
markdown += `- 删除 \`Metadata\` 类型声明\n\n`;
if (report.nextjsImports.length > 0) {
markdown += `**Next.js imports 需要移除**:\n`;
const grouped = {};
report.nextjsImports.forEach(item => {
if (!grouped[item.import]) grouped[item.import] = [];
grouped[item.import].push(item.file);
});
Object.entries(grouped).slice(0, 5).forEach(([imp, files]) => {
markdown += `- \`${imp}\` (${files.length} 个文件)\n`;
});
if (Object.keys(grouped).length > 5) {
markdown += `- *...还有 ${Object.keys(grouped).length - 5} 种 imports*\n`;
}
markdown += `\n`;
}
markdown += `### 任务 4: 安装依赖\n\n`;
if (report.dependencies.toInstall && report.dependencies.toInstall.length > 0) {
markdown += `**执行命令**:\n`;
markdown += `\`\`\`bash\n`;
markdown += `pnpm add ${report.dependencies.toInstall.join(' ')}\n`;
markdown += `\`\`\`\n\n`;
} else {
markdown += `✓ 无需安装额外依赖\n\n`;
}
markdown += `### 任务 5: 验收测试\n\n`;
markdown += `**执行命令**:\n`;
markdown += `\`\`\`bash\n`;
markdown += `node scripts/check-app-ready.mjs ${targetInfo.checkPath}\n`;
markdown += `\`\`\`\n\n`;
markdown += `**验收标准**: 状态为 READY页面正常渲染无控制台错误\n\n`;
markdown += `## 📚 参考资料\n\n`;
markdown += `- **转换规范**: \`/skills/v0-project-converter/SKILL.md\`\n`;
markdown += `- **原始项目**: \`${tempDir}\` (仅供参考)\n`;
markdown += `- **详细数据**: \`${targetInfo.analysisFileName}\`\n\n`;
markdown += `## 💡 注意事项\n\n`;
markdown += `1. ~~**"use client"**: Next.js 指令,必须删除~~ ✓ 已由脚本处理\n`;
markdown += `2. ~~**路径别名**: \`@/\` 需转换为相对路径~~ ✓ 已由脚本处理\n`;
markdown += `3. **原始文件**: \`${tempDir}\` 目录保留作为参考,不要修改\n`;
markdown += `4. **验证**: 完成后务必运行验收脚本确认\n`;
const mdPath = path.join(targetInfo.outputDir, targetInfo.tasksFileName);
fs.writeFileSync(mdPath, markdown);
return { reportPath, mdPath };
}
function generateThemeTasksDocument(report, targetInfo, outputName, tempDir) {
const reportPath = path.join(targetInfo.outputDir, targetInfo.analysisFileName);
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
let markdown = `# V0 主题导入任务清单\n\n`;
markdown += `> **重要**: 请先阅读 \`/skills/v0-project-converter/SKILL.md\` 与主题拆分技能文档,按任务顺序执行\n\n`;
markdown += `**主题 key**: ${outputName}\n`;
markdown += `**主题目录**: \`${targetInfo.relativeOutputDir}/\`\n`;
markdown += `**原始文件**: \`${tempDir}\` (仅供参考,不要修改)\n`;
markdown += `**生成时间**: ${new Date().toLocaleString()}\n\n`;
markdown += `## 📊 输入概况\n\n`;
markdown += `- 总文件数: ${report.summary.totalFiles}\n`;
markdown += `- 路径别名 (@/): ${report.summary.pathAliasCount} 处(已转换)\n`;
markdown += `- Next.js imports: ${report.summary.nextjsImportCount}\n`;
markdown += `- 依赖待评估: ${report.summary.dependenciesToInstall}\n\n`;
markdown += `## 📚 参考文档(必须阅读)\n\n`;
markdown += `- \`/skills/v0-project-converter/SKILL.md\`\n`;
THEME_SPLIT_SKILL_DOCS.forEach((docPath) => {
markdown += `- \`${docPath}\`\n`;
});
markdown += `\n`;
markdown += `## ✅ 主题导入任务(共 5 个)\n\n`;
markdown += `### 任务 1生成主题 token\n\n`;
markdown += `**目标**:在 \`${targetInfo.relativeOutputDir}/\` 下生成 \`globals.css\`\`designToken.json\`(二选一)\n\n`;
markdown += `**要求**\n`;
markdown += `- 若输出 \`designToken.json\`,必须包含 \`name\` 字段\n`;
markdown += `- 覆盖颜色、字体、间距、圆角、阴影等核心 token\n`;
markdown += `- 与输入项目视觉风格一致\n\n`;
markdown += `### 任务 2生成 DESIGN-SPEC.md\n\n`;
markdown += `**目标**:输出 \`${targetInfo.relativeOutputDir}/DESIGN-SPEC.md\`\n\n`;
markdown += `**要求**:说明设计语言、组件风格、排版层级、状态规范与使用建议\n\n`;
markdown += `### 任务 3按需生成项目文档\n\n`;
markdown += `**目标**:在 \`src/docs/\` 下补充主题相关项目文档(如页面地图/项目概览)\n\n`;
markdown += `**要求**:文档需与主题风格、信息结构和业务语境保持一致\n\n`;
markdown += `### 任务 4按需生成数据模型\n\n`;
markdown += `**目标**:在 \`src/database/\` 下补充或更新数据模型\n\n`;
markdown += `**要求**\n`;
markdown += `- 文件名英文、\`tableName\` 中文\n`;
markdown += `- 每个表包含 \`records\` 数组,记录 id 唯一\n\n`;
markdown += `### 任务 5生成/更新主题演示入口\n\n`;
markdown += `**目标**:生成或更新 \`${targetInfo.relativeOutputDir}/index.tsx\`\n\n`;
markdown += `**要求**\n`;
markdown += `- 演示主题 token 的核心效果\n`;
markdown += `- 若使用 \`designToken.json\`,演示中需体现 token 注入方式\n\n`;
markdown += `## 🔍 验收建议\n\n`;
markdown += `- 目录检查:\`${targetInfo.relativeOutputDir}/\` 是否包含 token 文件、\`DESIGN-SPEC.md\`\`index.tsx\`\n`;
markdown += `- 文档检查:\`src/docs/\` 是否按需补充\n`;
markdown += `- 数据检查:\`src/database/\` JSON 结构是否满足约束\n\n`;
markdown += `## 📎 产物索引\n\n`;
markdown += `- 任务清单:\`${targetInfo.tasksFileName}\`\n`;
markdown += `- 分析报告:\`${targetInfo.analysisFileName}\`\n`;
const mdPath = path.join(targetInfo.outputDir, targetInfo.tasksFileName);
fs.writeFileSync(mdPath, markdown);
return { reportPath, mdPath };
}
function parseArgs(rawArgs) {
const args = [...rawArgs];
const help = args.length === 0 || args.includes('--help') || args.includes('-h');
if (help) {
return { help: true };
}
let projectDirArg = '';
let outputNameArg = '';
let targetType = 'prototypes';
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === '--target-type') {
const nextValue = args[index + 1];
if (!nextValue) {
throw new Error('参数 --target-type 缺少值');
}
targetType = String(nextValue).trim();
index += 1;
continue;
}
if (!projectDirArg) {
projectDirArg = arg;
continue;
}
if (!outputNameArg) {
outputNameArg = arg;
continue;
}
}
if (!projectDirArg) {
throw new Error('缺少 <v0-project-dir> 参数');
}
if (!Object.prototype.hasOwnProperty.call(TARGET_TYPE_TO_SRC_DIR, targetType)) {
throw new Error(`不支持的 targetType: ${targetType}。可选值: ${Object.keys(TARGET_TYPE_TO_SRC_DIR).join(', ')}`);
}
const outputName = sanitizeName(outputNameArg || path.basename(projectDirArg));
if (!outputName) {
throw new Error('无法生成有效的输出名称,请显式传入 [output-name]');
}
return {
help: false,
projectDirArg,
outputName,
targetType,
};
}
function printHelp() {
console.log(`
V0 项目预处理器
使用方法:
node scripts/v0-converter.mjs <v0-project-dir> [output-name] [--target-type <prototypes|components|themes>]
示例:
node scripts/v0-converter.mjs "temp/my-v0-project" my-page
node scripts/v0-converter.mjs "temp/my-v0-project" brand-theme --target-type themes
功能:
- 完整复制 V0 项目(不修改代码)
- 生成 AI 工作文档(默认 .v0-tasks.md主题模式 .v0-theme-tasks.md
- 生成分析报告(默认 .v0-analysis.json主题模式 .v0-theme-analysis.json
`);
}
async function main() {
let parsed;
try {
parsed = parseArgs(process.argv.slice(2));
} catch (error) {
log(`参数错误: ${error.message}`, 'error');
printHelp();
process.exit(1);
}
if (parsed.help) {
printHelp();
process.exit(0);
}
const v0Dir = path.resolve(CONFIG.projectRoot, parsed.projectDirArg);
const targetInfo = getTargetInfo(parsed.targetType, parsed.outputName);
const outputDir = targetInfo.outputDir;
if (!fs.existsSync(v0Dir)) {
log(`错误: 找不到目录 ${v0Dir}`, 'error');
process.exit(1);
}
const appDir = path.join(v0Dir, 'app');
if (!fs.existsSync(appDir)) {
log('错误: 这不是一个有效的 V0 项目(缺少 app/ 目录)', 'error');
process.exit(1);
}
try {
ensureDir(targetInfo.outputBaseDir);
log(`开始预处理 V0 项目targetType=${parsed.targetType}...`, 'info');
log('步骤 1/4: 复制项目文件...', 'progress');
const fileCount = copyDirectory(v0Dir, outputDir);
log(`已复制 ${fileCount} 个文件`, 'info');
log('步骤 2/4: 复制 public/images 到目标目录...', 'progress');
const publicImagesDir = path.join(v0Dir, 'public/images');
const outputImagesDir = path.join(outputDir, 'images');
let imageCount = 0;
if (fs.existsSync(publicImagesDir)) {
imageCount = copyDirectory(publicImagesDir, outputImagesDir);
log(`已复制 ${imageCount} 个图片文件到 ${targetInfo.relativeOutputDir}/images/`, 'info');
} else {
log('未找到 public/images 目录,跳过', 'info');
}
log('步骤 3/4: 处理确定性转换(删除 "use client",转换路径别名)...', 'progress');
const processedCount = processFiles(outputDir);
log(`已处理 ${processedCount} 个文件`, 'info');
log('步骤 4/4: 分析项目并生成任务文档...', 'progress');
const analysis = analyzeProject(outputDir);
const report = buildAnalysisReport(analysis);
const { reportPath, mdPath } = parsed.targetType === 'themes'
? generateThemeTasksDocument(report, targetInfo, parsed.outputName, `temp/${path.basename(v0Dir)}`)
: generateDefaultTasksDocument(report, targetInfo, parsed.outputName, `temp/${path.basename(v0Dir)}`);
log('✅ 预处理完成!', 'info');
log('', 'info');
log(`📁 ${targetInfo.label}位置: ${targetInfo.relativeOutputDir}/`, 'info');
log(`📋 AI 工作文档: ${path.relative(CONFIG.projectRoot, mdPath)}`, 'info');
log(`📊 详细数据: ${path.relative(CONFIG.projectRoot, reportPath)}`, 'info');
log('', 'info');
log('📈 统计:', 'info');
log(` - 文件数: ${analysis.files.length}`, 'info');
log(` - 路径别名: ${analysis.pathAliases.length}`, 'info');
log(` - Next.js imports: ${analysis.nextjsImports.length}`, 'info');
log(` - 依赖: ${analysis.dependencies.toInstall?.length || 0}`, 'info');
log('', 'info');
log('🎯 下一步:', 'info');
log(`1. 查看任务文档: cat ${path.relative(CONFIG.projectRoot, mdPath)}`, 'info');
log(parsed.targetType === 'themes'
? '2. 让 AI 按任务单完成主题/文档/数据生成'
: '2. 让 AI 根据任务清单完成转换', 'info');
} catch (error) {
log(`预处理失败: ${error.message}`, 'error');
console.error(error);
process.exit(1);
}
}
main();