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>
914 lines
35 KiB
JavaScript
914 lines
35 KiB
JavaScript
#!/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();
|