#!/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 += `- 替换组件: \`\` → \`\`, \`\` → \`\`\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('缺少 参数'); } 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 [output-name] [--target-type ] 示例: 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();