Initial commit: 小羚羚小程序 Axhub Make workspace.
Include xll-miniapp prototype, PRD resources, annotation directory sync, agent skills, and cloud publishing setup. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
115
scripts/ai-studio-converter.mjs
Normal file
115
scripts/ai-studio-converter.mjs
Normal file
@@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
function normalizeSlashes(input) {
|
||||
return String(input || '').replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
function sanitizeName(rawName) {
|
||||
return String(rawName || '')
|
||||
.replace(/[^a-z0-9-]/gi, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = [...argv];
|
||||
const projectDirArg = args.shift();
|
||||
const outputNameArg = args.shift();
|
||||
let targetType = 'prototypes';
|
||||
let projectRoot = process.cwd();
|
||||
let outputBaseDir = '';
|
||||
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
if (arg === '--target-type') {
|
||||
targetType = String(args[index + 1] || '').trim();
|
||||
index += 1;
|
||||
} else if (arg === '--project-root') {
|
||||
projectRoot = path.resolve(args[index + 1] || projectRoot);
|
||||
index += 1;
|
||||
} else if (arg === '--output-base-dir') {
|
||||
outputBaseDir = path.resolve(args[index + 1] || '');
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (!projectDirArg) throw new Error('Missing project directory');
|
||||
const outputName = sanitizeName(outputNameArg || path.basename(projectDirArg));
|
||||
if (!outputName) throw new Error('Missing valid output name');
|
||||
return {
|
||||
projectDir: path.resolve(projectRoot, projectDirArg),
|
||||
outputName,
|
||||
targetType,
|
||||
projectRoot,
|
||||
outputBaseDir: outputBaseDir || path.resolve(projectRoot, 'src', targetType),
|
||||
};
|
||||
}
|
||||
|
||||
function copyDirectory(src, dest) {
|
||||
if (!fs.existsSync(src)) return 0;
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
let count = 0;
|
||||
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
||||
if (entry.name === 'node_modules') continue;
|
||||
const srcPath = path.join(src, entry.name);
|
||||
const destPath = path.join(dest, entry.name);
|
||||
if (entry.isDirectory()) count += copyDirectory(srcPath, destPath);
|
||||
else if (entry.isFile()) {
|
||||
fs.copyFileSync(srcPath, destPath);
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function ensureIndex(outputDir) {
|
||||
const indexPath = path.join(outputDir, 'index.tsx');
|
||||
if (!fs.existsSync(indexPath)) {
|
||||
fs.writeFileSync(indexPath, [
|
||||
"import React from 'react';",
|
||||
'',
|
||||
'export default function ImportedAiStudioPrototype() {',
|
||||
' return <div data-import-source="google_aistudio">AI Studio import requires AI conversion.</div>;',
|
||||
'}',
|
||||
'',
|
||||
].join('\n'), 'utf8');
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const parsed = parseArgs(process.argv.slice(2));
|
||||
if (!fs.existsSync(path.join(parsed.projectDir, 'App.tsx')) && !fs.existsSync(path.join(parsed.projectDir, 'index.html'))) {
|
||||
throw new Error('这不是一个有效的 AI Studio 项目(缺少 App.tsx 或 index.html)');
|
||||
}
|
||||
const outputDir = path.join(parsed.outputBaseDir, parsed.outputName);
|
||||
fs.rmSync(outputDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(parsed.outputBaseDir, { recursive: true });
|
||||
const fileCount = copyDirectory(parsed.projectDir, outputDir);
|
||||
ensureIndex(outputDir);
|
||||
const taskPath = path.join(outputDir, parsed.targetType === 'themes' ? '.ai-studio-theme-tasks.md' : '.ai-studio-tasks.md');
|
||||
fs.writeFileSync(taskPath, [
|
||||
'# AI Studio 项目转换任务清单',
|
||||
'',
|
||||
'> 请先阅读 `rules/ai-studio-project-converter.md` 和 `rules/development-guide.md`,再基于该目录完成原型转换。',
|
||||
'',
|
||||
`- 输出目录:\`${normalizeSlashes(path.relative(parsed.projectRoot, outputDir))}/\``,
|
||||
`- 已复制文件数:${fileCount}`,
|
||||
'',
|
||||
].join('\n'), 'utf8');
|
||||
console.log(JSON.stringify({
|
||||
success: true,
|
||||
outputDir,
|
||||
tasksFile: normalizeSlashes(path.relative(parsed.projectRoot, taskPath)),
|
||||
}));
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
console.error(error?.message || String(error));
|
||||
process.exit(1);
|
||||
}
|
||||
48
scripts/build-all.js
Normal file
48
scripts/build-all.js
Normal 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, ['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所有入口构建完成 ✅');
|
||||
2
scripts/canvas-fig-sync.mjs
Normal file
2
scripts/canvas-fig-sync.mjs
Normal file
@@ -0,0 +1,2 @@
|
||||
#!/usr/bin/env node
|
||||
import '../../vendor/axhub-export-core/scripts/canvas-fig-sync.mjs';
|
||||
1025
scripts/capture-theme-homepage.mjs
Normal file
1025
scripts/capture-theme-homepage.mjs
Normal file
File diff suppressed because it is too large
Load Diff
272
scripts/capture-theme-homepage.test.mjs
Normal file
272
scripts/capture-theme-homepage.test.mjs
Normal file
@@ -0,0 +1,272 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
findWorkspaceRoot,
|
||||
normalizeViewport,
|
||||
parseCaptureArgs,
|
||||
resolveChromiumLaunchOptions,
|
||||
resolveCaptureJobs,
|
||||
resolveScreenshotBox,
|
||||
resolveScreenshotPlan,
|
||||
resolveWidthNormalization,
|
||||
} from './capture-theme-homepage.mjs';
|
||||
|
||||
function makeFixture() {
|
||||
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'theme-capture-'));
|
||||
const appRoot = path.join(workspaceRoot, 'apps', 'make-project');
|
||||
const themeRoot = path.join(appRoot, 'src', 'themes', 'linear');
|
||||
fs.mkdirSync(themeRoot, { recursive: true });
|
||||
fs.writeFileSync(path.join(workspaceRoot, 'pnpm-workspace.yaml'), 'packages: []\n');
|
||||
fs.writeFileSync(
|
||||
path.join(themeRoot, 'theme.json'),
|
||||
JSON.stringify({
|
||||
source: {
|
||||
websiteUrl: 'https://linear.app/',
|
||||
originalDetailUrl: 'https://getdesign.md/linear.app/design-md',
|
||||
},
|
||||
}, null, 2),
|
||||
);
|
||||
return { workspaceRoot, appRoot };
|
||||
}
|
||||
|
||||
describe('capture-theme-homepage CLI planning', () => {
|
||||
it('parses screenshot options needed for stable long homepage captures', () => {
|
||||
const args = parseCaptureArgs([
|
||||
'node',
|
||||
'capture-theme-homepage.mjs',
|
||||
'--',
|
||||
'--theme',
|
||||
'linear',
|
||||
'--url',
|
||||
'https://linear.app',
|
||||
'--viewport',
|
||||
'1440x1200',
|
||||
'--wait-for-selector',
|
||||
'main',
|
||||
'--dismiss-selector',
|
||||
'button:has-text("Accept")',
|
||||
'--remove-selector',
|
||||
'.newsletter-modal',
|
||||
'--header',
|
||||
'Authorization=Bearer token',
|
||||
'--connect-cdp',
|
||||
'http://localhost:9222',
|
||||
'--storage-state',
|
||||
'.local/auth/linear.json',
|
||||
'--hide-scrollbar',
|
||||
'--scroll-warmup',
|
||||
'--quality',
|
||||
'92',
|
||||
]);
|
||||
|
||||
expect(args.theme).toBe('linear');
|
||||
expect(args.url).toBe('https://linear.app');
|
||||
expect(args.viewport).toEqual({ width: 1440, height: 1200 });
|
||||
expect(args.waitForSelectors).toEqual(['main']);
|
||||
expect(args.dismissSelectors).toEqual(['button:has-text("Accept")']);
|
||||
expect(args.removeSelectors).toEqual(['.newsletter-modal']);
|
||||
expect(args.headers).toEqual({ Authorization: 'Bearer token' });
|
||||
expect(args.connectCdp).toBe('http://localhost:9222');
|
||||
expect(args.storageState).toBe('.local/auth/linear.json');
|
||||
expect(args.hideScrollbar).toBe(true);
|
||||
expect(args.scrollWarmup).toBe(true);
|
||||
expect(args.quality).toBe(92);
|
||||
});
|
||||
|
||||
it('resolves a single theme from theme.json metadata into the official homepage asset path', () => {
|
||||
const { workspaceRoot, appRoot } = makeFixture();
|
||||
const args = parseCaptureArgs(['node', 'capture-theme-homepage.mjs', '--theme', 'linear']);
|
||||
|
||||
const jobs = resolveCaptureJobs(args, { appRoot, workspaceRoot });
|
||||
|
||||
expect(jobs).toHaveLength(1);
|
||||
expect(jobs[0].theme).toBe('linear');
|
||||
expect(jobs[0].url).toBe('https://linear.app/');
|
||||
expect(jobs[0].outputPath).toBe(path.join(appRoot, 'src/themes/linear/assets/official-homepage.webp'));
|
||||
expect(jobs[0].reportPath).toBe(path.join(workspaceRoot, '.local/theme-captures/linear/meta.json'));
|
||||
expect(jobs[0].viewport).toEqual({ width: 1440, height: 1200 });
|
||||
expect(jobs[0].waitUntil).toBe('load');
|
||||
expect(jobs[0].hideScrollbar).toBe(true);
|
||||
expect(jobs[0].scrollWarmup).toBe(true);
|
||||
expect(jobs[0]).not.toHaveProperty('dryRun');
|
||||
expect(jobs[0]).not.toHaveProperty('help');
|
||||
});
|
||||
|
||||
it('merges config defaults, per-theme settings, and CLI overrides for batch capture', () => {
|
||||
const { workspaceRoot, appRoot } = makeFixture();
|
||||
const configPath = path.join(workspaceRoot, 'theme-capture.json');
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify({
|
||||
defaults: {
|
||||
viewport: '1920x1080',
|
||||
waitForSelectors: ['main'],
|
||||
removeSelectors: ['.chat-widget'],
|
||||
headers: { 'X-Capture': 'theme' },
|
||||
},
|
||||
themes: {
|
||||
linear: {
|
||||
url: 'https://linear.app/custom',
|
||||
dismissSelectors: ['button:has-text("Accept all")'],
|
||||
waitAfterLoadMs: 2500,
|
||||
},
|
||||
stripe: 'https://stripe.com',
|
||||
},
|
||||
}, null, 2),
|
||||
);
|
||||
|
||||
const args = parseCaptureArgs([
|
||||
'node',
|
||||
'capture-theme-homepage.mjs',
|
||||
'--config',
|
||||
configPath,
|
||||
'--theme',
|
||||
'linear',
|
||||
'--viewport',
|
||||
'1366x900',
|
||||
'--header',
|
||||
'Authorization=Bearer local',
|
||||
]);
|
||||
|
||||
const jobs = resolveCaptureJobs(args, { appRoot, workspaceRoot });
|
||||
|
||||
expect(jobs).toHaveLength(1);
|
||||
expect(jobs[0]).toMatchObject({
|
||||
theme: 'linear',
|
||||
url: 'https://linear.app/custom',
|
||||
viewport: { width: 1366, height: 900 },
|
||||
waitForSelectors: ['main'],
|
||||
removeSelectors: ['.chat-widget'],
|
||||
dismissSelectors: ['button:has-text("Accept all")'],
|
||||
waitAfterLoadMs: 2500,
|
||||
headers: {
|
||||
'X-Capture': 'theme',
|
||||
Authorization: 'Bearer local',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('expands all config themes when --all is requested', () => {
|
||||
const { workspaceRoot, appRoot } = makeFixture();
|
||||
const configPath = path.join(workspaceRoot, 'theme-capture.json');
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify({
|
||||
themes: [
|
||||
{ theme: 'linear', url: 'https://linear.app' },
|
||||
{ theme: 'stripe', url: 'https://stripe.com' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const args = parseCaptureArgs(['node', 'capture-theme-homepage.mjs', '--config', configPath, '--all']);
|
||||
|
||||
expect(resolveCaptureJobs(args, { appRoot, workspaceRoot }).map((job) => job.theme)).toEqual([
|
||||
'linear',
|
||||
'stripe',
|
||||
]);
|
||||
});
|
||||
|
||||
it('finds the workspace root and rejects invalid viewport strings', () => {
|
||||
const { workspaceRoot, appRoot } = makeFixture();
|
||||
|
||||
expect(findWorkspaceRoot(appRoot)).toBe(workspaceRoot);
|
||||
expect(() => normalizeViewport('wide')).toThrow('Invalid viewport');
|
||||
});
|
||||
|
||||
it('builds launch options with an explicit or discovered Chrome executable fallback', () => {
|
||||
expect(resolveChromiumLaunchOptions({
|
||||
headless: true,
|
||||
browserExecutable: '/tmp/Test Chrome',
|
||||
}).executablePath).toBe('/tmp/Test Chrome');
|
||||
|
||||
const discovered = resolveChromiumLaunchOptions(
|
||||
{ headless: false },
|
||||
{
|
||||
candidatePaths: ['/missing/chrome', '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'],
|
||||
exists: (candidate) => candidate.includes('Google Chrome'),
|
||||
},
|
||||
);
|
||||
|
||||
expect(discovered.headless).toBe(false);
|
||||
expect(discovered.executablePath).toBe('/Applications/Google Chrome.app/Contents/MacOS/Google Chrome');
|
||||
expect(discovered.args).toContain('--disable-blink-features=AutomationControlled');
|
||||
});
|
||||
|
||||
it('captures webp outputs through a temporary png screenshot plan', () => {
|
||||
const plan = resolveScreenshotPlan({
|
||||
theme: 'linear',
|
||||
format: 'webp',
|
||||
outputPath: '/tmp/linear/official-homepage.webp',
|
||||
reportDir: '/tmp/.local/theme-captures/linear',
|
||||
});
|
||||
|
||||
expect(plan).toEqual({
|
||||
playwrightType: 'png',
|
||||
screenshotPath: '/tmp/.local/theme-captures/linear/official-homepage-source.png',
|
||||
outputPath: '/tmp/linear/official-homepage.webp',
|
||||
needsConversion: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('clips very tall webp screenshots to the encoder-safe height', () => {
|
||||
expect(resolveScreenshotBox({
|
||||
format: 'webp',
|
||||
viewport: { width: 1440, height: 1200 },
|
||||
maxScreenshotHeight: 16383,
|
||||
}, {
|
||||
scrollHeight: 24000,
|
||||
})).toEqual({
|
||||
fullPage: false,
|
||||
viewport: { width: 1440, height: 16383 },
|
||||
clipped: true,
|
||||
sourceHeight: 24000,
|
||||
outputHeight: 16383,
|
||||
});
|
||||
|
||||
expect(resolveScreenshotBox({
|
||||
format: 'webp',
|
||||
viewport: { width: 1440, height: 1200 },
|
||||
maxScreenshotHeight: 16383,
|
||||
}, {
|
||||
scrollHeight: 8000,
|
||||
})).toEqual({
|
||||
fullPage: true,
|
||||
clipped: false,
|
||||
sourceHeight: 8000,
|
||||
outputHeight: 8000,
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes horizontally overflowing screenshots back to the viewport width', () => {
|
||||
expect(resolveWidthNormalization({
|
||||
viewport: { width: 1440, height: 1200 },
|
||||
}, {
|
||||
width: 3870,
|
||||
height: 6517,
|
||||
})).toEqual({
|
||||
needed: true,
|
||||
width: 1440,
|
||||
height: 6517,
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
|
||||
expect(resolveWidthNormalization({
|
||||
viewport: { width: 1440, height: 1200 },
|
||||
}, {
|
||||
width: 1440,
|
||||
height: 6517,
|
||||
})).toEqual({
|
||||
needed: false,
|
||||
width: 1440,
|
||||
height: 6517,
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
878
scripts/check-app-ready.mjs
Normal file
878
scripts/check-app-ready.mjs
Normal file
@@ -0,0 +1,878 @@
|
||||
#!/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 /prototypes/ref-app-home
|
||||
* node scripts/check-app-ready.mjs /prototypes/home
|
||||
*
|
||||
* 跳过构建校验:
|
||||
* node scripts/check-app-ready.mjs --skip-build /prototypes/ref-app-home
|
||||
*
|
||||
* 输出(JSON):
|
||||
* {
|
||||
* status: "READY" | "ERROR" | "TIMEOUT",
|
||||
* phase: "server|build|page|done",
|
||||
* message: "...",
|
||||
* url: "http://localhost:51720/prototypes/ref-app-home",
|
||||
* 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/prototypes/ref-app-home"
|
||||
* 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('themes/') ||
|
||||
keys[0] ||
|
||||
null
|
||||
)
|
||||
} catch (err) {
|
||||
logs.push(`Failed to resolve default entry key: ${err.message}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从页面路径推断入口 key
|
||||
* 例如:/prototypes/ref-app-home -> prototypes/ref-app-home
|
||||
*/
|
||||
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()
|
||||
873
scripts/chrome-export-converter.mjs
Normal file
873
scripts/chrome-export-converter.mjs
Normal file
@@ -0,0 +1,873 @@
|
||||
#!/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(/&/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(/&/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;
|
||||
|
||||
nextContent = nextContent.replace(/\s(?:srcset|sizes)=(["'])\1/gi, '');
|
||||
|
||||
JSX_ATTRIBUTE_REPLACEMENTS.forEach(([from, to]) => {
|
||||
nextContent = nextContent.replace(new RegExp(`(\\s)${from}=`, 'gi'), `$1${to}=`);
|
||||
});
|
||||
|
||||
return nextContent;
|
||||
}
|
||||
|
||||
function findCompatibleImageAsset(filename, availableAssets) {
|
||||
if (!filename || availableAssets.has(filename)) {
|
||||
return filename;
|
||||
}
|
||||
|
||||
const extension = path.extname(filename);
|
||||
const basename = path.basename(filename, extension);
|
||||
const dedupeMatch = basename.match(/^(.*?)-\d+$/);
|
||||
if (!dedupeMatch) {
|
||||
return filename;
|
||||
}
|
||||
|
||||
const fallbackName = `${dedupeMatch[1]}${extension}`;
|
||||
return availableAssets.has(fallbackName) ? fallbackName : filename;
|
||||
}
|
||||
|
||||
function reconcileImageAssetReferences(content, imagesPath) {
|
||||
if (!content || !fs.existsSync(imagesPath)) {
|
||||
return content;
|
||||
}
|
||||
|
||||
const availableAssets = new Set(
|
||||
fs.readdirSync(imagesPath, { withFileTypes: true })
|
||||
.filter((entry) => entry.isFile())
|
||||
.map((entry) => entry.name),
|
||||
);
|
||||
|
||||
return content.replace(/assets\/images\/([^"')\s>]+)/g, (fullMatch, filename) => {
|
||||
const resolvedFilename = findCompatibleImageAsset(filename, availableAssets);
|
||||
if (resolvedFilename === filename) {
|
||||
return fullMatch;
|
||||
}
|
||||
return `assets/images/${resolvedFilename}`;
|
||||
});
|
||||
}
|
||||
|
||||
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(/<\//g, '__LTSLASH__')
|
||||
.replace(/</g, '__LT__')
|
||||
.replace(/>/g, '__GT__')
|
||||
.replace(/&/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 openTagMatch = html.match(/<body\b[^>]*>/i);
|
||||
if (!openTagMatch || openTagMatch.index === undefined) return '';
|
||||
|
||||
const openTag = openTagMatch[0];
|
||||
const contentStart = openTagMatch.index + openTag.length;
|
||||
const closeTagIndex = html.toLowerCase().lastIndexOf('</body>');
|
||||
if (closeTagIndex < contentStart) return '';
|
||||
|
||||
const innerContent = html.slice(contentStart, closeTagIndex);
|
||||
|
||||
// 移除 <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(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/&/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
|
||||
* - /rules/default-resource-recommendations.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 = reconcileImageAssetReferences(
|
||||
extractBodyContent(html),
|
||||
path.join(sourcePath, 'assets', 'images'),
|
||||
);
|
||||
|
||||
// 从外部 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,
|
||||
reconcileImageAssetReferences,
|
||||
};
|
||||
147
scripts/figma-make-converter.mjs
Normal file
147
scripts/figma-make-converter.mjs
Normal file
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const TASK_FILE_BY_TARGET = {
|
||||
prototypes: '.figma-make-tasks.md',
|
||||
components: '.figma-make-tasks.md',
|
||||
themes: '.figma-make-theme-tasks.md',
|
||||
};
|
||||
|
||||
function normalizeSlashes(input) {
|
||||
return String(input || '').replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
function sanitizeName(rawName) {
|
||||
return String(rawName || '')
|
||||
.replace(/[^a-z0-9-]/gi, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = [...argv];
|
||||
const projectDirArg = args.shift();
|
||||
const outputNameArg = args.shift();
|
||||
let targetType = 'prototypes';
|
||||
let projectRoot = process.cwd();
|
||||
let outputBaseDir = '';
|
||||
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
if (arg === '--target-type') {
|
||||
targetType = String(args[index + 1] || '').trim();
|
||||
index += 1;
|
||||
} else if (arg === '--project-root') {
|
||||
projectRoot = path.resolve(args[index + 1] || projectRoot);
|
||||
index += 1;
|
||||
} else if (arg === '--output-base-dir') {
|
||||
outputBaseDir = path.resolve(args[index + 1] || '');
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (!projectDirArg) {
|
||||
throw new Error('Missing project directory');
|
||||
}
|
||||
if (!TASK_FILE_BY_TARGET[targetType]) {
|
||||
throw new Error(`Unsupported targetType: ${targetType}`);
|
||||
}
|
||||
const outputName = sanitizeName(outputNameArg || path.basename(projectDirArg));
|
||||
if (!outputName) {
|
||||
throw new Error('Missing valid output name');
|
||||
}
|
||||
|
||||
return {
|
||||
projectDir: path.resolve(projectRoot, projectDirArg),
|
||||
outputName,
|
||||
targetType,
|
||||
projectRoot,
|
||||
outputBaseDir: outputBaseDir || path.resolve(projectRoot, 'src', targetType),
|
||||
};
|
||||
}
|
||||
|
||||
function copyDirectory(src, dest) {
|
||||
if (!fs.existsSync(src)) return 0;
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
let count = 0;
|
||||
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
||||
if (entry.name === 'node_modules' || entry.name === '.npm-local-cache' || entry.name === 'build') {
|
||||
continue;
|
||||
}
|
||||
const srcPath = path.join(src, entry.name);
|
||||
const destPath = path.join(dest, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
count += copyDirectory(srcPath, destPath);
|
||||
} else if (entry.isFile()) {
|
||||
fs.copyFileSync(srcPath, destPath);
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function ensureIndex(outputDir) {
|
||||
const indexPath = path.join(outputDir, 'index.tsx');
|
||||
if (!fs.existsSync(indexPath)) {
|
||||
fs.writeFileSync(indexPath, [
|
||||
"import React from 'react';",
|
||||
'',
|
||||
'export default function ImportedFigmaMakePrototype() {',
|
||||
' return <div data-import-source="figma_make">Figma Make import requires AI conversion.</div>;',
|
||||
'}',
|
||||
'',
|
||||
].join('\n'), 'utf8');
|
||||
}
|
||||
}
|
||||
|
||||
function writeTaskFile(params) {
|
||||
const taskFileName = TASK_FILE_BY_TARGET[params.targetType];
|
||||
const taskPath = path.join(params.outputDir, taskFileName);
|
||||
const relativeOutput = normalizeSlashes(path.relative(params.projectRoot, params.outputDir));
|
||||
const sourceContext = normalizeSlashes(path.relative(params.projectRoot, params.projectDir));
|
||||
const content = [
|
||||
'# Figma Make 项目转换任务清单',
|
||||
'',
|
||||
'> 请先阅读 `rules/development-guide.md`、`rules/design-guide.md` 和 `rules/default-resource-recommendations.md`,再基于该目录完成原型转换。',
|
||||
'',
|
||||
`- 输出目录:\`${relativeOutput}/\``,
|
||||
`- 上传上下文:\`${sourceContext}/\``,
|
||||
`- 已复制文件数:${params.fileCount}`,
|
||||
'',
|
||||
'## 执行要求',
|
||||
'- 保留原始 Figma Make 项目结构与素材。',
|
||||
'- 将可运行原型入口整理到 `index.tsx`。',
|
||||
'- 如已有 `src/App.tsx`,优先复用其页面结构。',
|
||||
'',
|
||||
].join('\n');
|
||||
fs.writeFileSync(taskPath, content, 'utf8');
|
||||
return taskPath;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const parsed = parseArgs(process.argv.slice(2));
|
||||
if (!fs.existsSync(path.join(parsed.projectDir, 'src')) || !fs.existsSync(path.join(parsed.projectDir, 'package.json'))) {
|
||||
throw new Error('这不是一个有效的 Figma Make 项目(需要包含 src/ 和 package.json)');
|
||||
}
|
||||
const outputDir = path.join(parsed.outputBaseDir, parsed.outputName);
|
||||
fs.rmSync(outputDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(parsed.outputBaseDir, { recursive: true });
|
||||
const fileCount = copyDirectory(parsed.projectDir, outputDir);
|
||||
ensureIndex(outputDir);
|
||||
const taskPath = writeTaskFile({ ...parsed, outputDir, fileCount });
|
||||
console.log(JSON.stringify({
|
||||
success: true,
|
||||
outputDir,
|
||||
tasksFile: normalizeSlashes(path.relative(parsed.projectRoot, taskPath)),
|
||||
}));
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
console.error(error?.message || String(error));
|
||||
process.exit(1);
|
||||
}
|
||||
532
scripts/generate-audit-testcases.py
Normal file
532
scripts/generate-audit-testcases.py
Normal file
@@ -0,0 +1,532 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate audit center test cases Excel for xll-miniapp."""
|
||||
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
|
||||
from openpyxl.utils import get_column_letter
|
||||
from datetime import date
|
||||
import os
|
||||
|
||||
DESKTOP = os.path.expanduser("~/Desktop")
|
||||
OUTPUT = os.path.join(DESKTOP, f"小羚羚-审批中心测试用例-{date.today().isoformat()}.xlsx")
|
||||
|
||||
HEADERS = [
|
||||
"用例编号",
|
||||
"测试模块",
|
||||
"流程类型",
|
||||
"测试场景",
|
||||
"前置条件",
|
||||
"操作步骤",
|
||||
"测试数据(Mock)",
|
||||
"预期结果",
|
||||
"优先级",
|
||||
"关联需求/标注",
|
||||
]
|
||||
|
||||
CASES = []
|
||||
|
||||
def add(module, flow, scene, pre, steps, data, expect, pri, ref):
|
||||
CASES.append([module, flow, scene, pre, steps, data, expect, pri, ref])
|
||||
|
||||
# ========== 提车应收款 ==========
|
||||
add(
|
||||
"提车应收款",
|
||||
"提车应收款",
|
||||
"列表-待办卡片展示",
|
||||
"打开审批中心;当前用户=张明辉;Tab=我的待办",
|
||||
"1. 进入 #page=audit\n2. 查看「我的待办」列表",
|
||||
"单据 TC-2026-0312;客户=上海迅杰物流有限公司",
|
||||
"卡片左上角标签「提车应收款」;加粗标题=客户全称;副标题=项目名称;元信息含发起人与实收金额;底部发起时间精确至分钟;显示「去审批」",
|
||||
"P0",
|
||||
"PRD01 §3.3/§5.3;page-audit;page-audit-pickup",
|
||||
)
|
||||
add(
|
||||
"提车应收款",
|
||||
"提车应收款",
|
||||
"列表-实收金额口径(不含氢费预付款)",
|
||||
"同上",
|
||||
"1. 找到 TC-2026-0312 卡片\n2. 核对实收金额",
|
||||
"3台车明细:租金+服务费+保证金−减免,不含氢费预付款",
|
||||
"列表实收金额=¥218,510.00(与车辆明细动态汇总一致,不含氢费预付款 4100)",
|
||||
"P0",
|
||||
"PRD01 §5.3;ap-2 mock",
|
||||
)
|
||||
add(
|
||||
"提车应收款",
|
||||
"提车应收款",
|
||||
"搜索-按客户全称",
|
||||
"审批中心列表",
|
||||
"1. 搜索框输入「上海迅杰」\n2. 观察筛选结果",
|
||||
"搜索关键词=上海迅杰",
|
||||
"仅展示加粗标题匹配的任务;TC-2026-0312 可见",
|
||||
"P1",
|
||||
"PRD01 §3.2;page-audit",
|
||||
)
|
||||
add(
|
||||
"提车应收款",
|
||||
"提车应收款",
|
||||
"Chip筛选",
|
||||
"审批中心",
|
||||
"1. 点击流程 Chip「提车应收款」",
|
||||
"",
|
||||
"列表仅展示 flowType=提车应收款 的任务",
|
||||
"P1",
|
||||
"PRD01 §3.2",
|
||||
)
|
||||
add(
|
||||
"提车应收款",
|
||||
"提车应收款",
|
||||
"详情-Hero实收金额",
|
||||
"待办 TC-2026-0312",
|
||||
"1. 点击卡片进入详情\n2. 查看 Hero 区",
|
||||
"TC-2026-0312",
|
||||
"Hero 实收款总额与列表卡片同一口径(不含氢费预付款);展示客户/合同/项目信息",
|
||||
"P0",
|
||||
"PRD01 §5.3;page-audit-pickup",
|
||||
)
|
||||
add(
|
||||
"提车应收款",
|
||||
"提车应收款",
|
||||
"详情-车辆明细间距",
|
||||
"进入 TC-2026-0312 详情",
|
||||
"1. 滚动至车辆明细区块\n2. 检查标题与首卡间距",
|
||||
"",
|
||||
"区块标题行与首张车辆卡片之间保留约 12px 上间距",
|
||||
"P2",
|
||||
"PRD01 §5.2;page-audit-pickup",
|
||||
)
|
||||
add(
|
||||
"提车应收款",
|
||||
"提车应收款",
|
||||
"详情-氢费预付款展示",
|
||||
"进入 TC-2026-0312 详情",
|
||||
"1. 查看氢费预付款区块",
|
||||
"",
|
||||
"详情可展示氢费预付款信息,但不计入列表/Hero 实收总额",
|
||||
"P1",
|
||||
"PRD01 §5.3",
|
||||
)
|
||||
add(
|
||||
"提车应收款",
|
||||
"提车应收款",
|
||||
"详情-审批时间轴",
|
||||
"待办 TC-2026-0312",
|
||||
"1. 查看审批情况时间轴",
|
||||
"",
|
||||
"节点展示审批人、状态 Tag、时间;含评论气泡(如业务总负责人节点)",
|
||||
"P1",
|
||||
"PRD01 §4.4/§5.2",
|
||||
)
|
||||
add(
|
||||
"提车应收款",
|
||||
"提车应收款",
|
||||
"待办-审批通过弹窗",
|
||||
"待办 TC-2026-0312 详情",
|
||||
"1. 点击底部「通过」\n2. 观察弹窗",
|
||||
"",
|
||||
"手机预览框内自底部滑出半屏表单;标题「审批通过」;含通知方式、附件、抄送人、审批意见;取消/确认",
|
||||
"P0",
|
||||
"tc-btn-approve 标注;PRD01 §3.5",
|
||||
)
|
||||
add(
|
||||
"提车应收款",
|
||||
"提车应收款",
|
||||
"待办-驳回/终止/评论",
|
||||
"待办 TC-2026-0312 详情",
|
||||
"1. 分别点击驳回、终止、评论",
|
||||
"",
|
||||
"均为框内半屏 sheet;驳回/终止字段与通过一致(标题不同)",
|
||||
"P1",
|
||||
"PRD01 §3.5",
|
||||
)
|
||||
add(
|
||||
"提车应收款",
|
||||
"提车应收款",
|
||||
"已办-只读查看",
|
||||
"Tab=我的已办;用户=财务-赵敏 或切换至 ap-12",
|
||||
"1. 打开 TC-2026-0228(嘉兴某某物流)详情",
|
||||
"ap-12 已通过",
|
||||
"无底部操作栏;可浏览 Hero、车辆明细、审批时间轴",
|
||||
"P1",
|
||||
"PRD01 §3.5",
|
||||
)
|
||||
add(
|
||||
"提车应收款",
|
||||
"提车应收款",
|
||||
"抄送 Tab 可见性",
|
||||
"Tab=抄送我的;用户=张明辉",
|
||||
"1. 查看是否含 TC-2026-0312",
|
||||
"ccUsers 含张明辉",
|
||||
"抄送列表可见该任务;进入为查看态",
|
||||
"P2",
|
||||
"acFilterByTab cc",
|
||||
)
|
||||
|
||||
# ========== 还车应结款主流程 ==========
|
||||
add(
|
||||
"还车应结款主流程",
|
||||
"还车应结款",
|
||||
"场景A-主流程待办展示",
|
||||
"Tab=我的待办;用户=张明辉",
|
||||
"1. 点击 Chip「还车应结款」\n2. 找到粤B58888F 主流程卡片",
|
||||
"HC-2026-0418 / ap-14;四部门已通过且 mainFlowInitiated=true",
|
||||
"卡片标签「还车应结款」;加粗标题=粤B58888F;展示应退还总额;状态=审批中·财务审核",
|
||||
"P0",
|
||||
"PRD01 §4.1 场景A;page-audit-return",
|
||||
)
|
||||
add(
|
||||
"还车应结款主流程",
|
||||
"还车应结款",
|
||||
"场景C-主流程不出现在列表",
|
||||
"Tab=我的待办/全部",
|
||||
"1. Chip「还车应结款」\n2. 搜索苏E12345F",
|
||||
"HC-2026-0610 / ap-26;四部门已通过但 mainFlowInitiated=false",
|
||||
"主流程「还车应结款」任务不在审批列表;四部门子流程可在已办/历史中出现",
|
||||
"P0",
|
||||
"PRD01 §4.1 场景C",
|
||||
)
|
||||
add(
|
||||
"还车应结款主流程",
|
||||
"还车应结款",
|
||||
"Chip 匹配主流程+子流程",
|
||||
"审批中心",
|
||||
"1. 点击「还车应结款」Chip",
|
||||
"",
|
||||
"列表同时包含主流程与四部门子流程任务",
|
||||
"P1",
|
||||
"PRD01 §3.2",
|
||||
)
|
||||
add(
|
||||
"还车应结款主流程",
|
||||
"还车应结款",
|
||||
"搜索-按车牌",
|
||||
"审批中心",
|
||||
"1. 搜索「粤B58888F」",
|
||||
"",
|
||||
"匹配主流程及同车牌的四部门子流程(标题均为车牌)",
|
||||
"P1",
|
||||
"PRD01 §3.2;列表标题规则(当前实现)",
|
||||
)
|
||||
add(
|
||||
"还车应结款主流程",
|
||||
"还车应结款",
|
||||
"详情-Hero应退应补",
|
||||
"待办 ap-14 主流程详情",
|
||||
"1. 进入粤B58888F 主流程\n2. 查看 Hero",
|
||||
"deposit=5000;pendingSettle=927.50;refundTotal=4072.50",
|
||||
"Hero 展示应退还总额 ¥4,072.50;含保证金、待结算总额、车辆实际租金;有「结算明细」入口",
|
||||
"P0",
|
||||
"PRD01 §4.2",
|
||||
)
|
||||
add(
|
||||
"还车应结款主流程",
|
||||
"还车应结款",
|
||||
"详情-四部门分组卡片",
|
||||
"主流程 ap-14 详情",
|
||||
"1. 查看四张部门费用卡片",
|
||||
"",
|
||||
"业务管理组/能源服务组/运维组/安全部分组展示;右上角均为「已提交」(绿色)",
|
||||
"P0",
|
||||
"hc-settle-group-status 标注;PRD01 §4.3",
|
||||
)
|
||||
add(
|
||||
"还车应结款主流程",
|
||||
"还车应结款",
|
||||
"详情-运维组无忧包减免列",
|
||||
"主流程 ap-14 详情",
|
||||
"1. 展开运维组费用表",
|
||||
"",
|
||||
"表格含序号/费用项/金额/无忧包减免;应结算=金额−减免",
|
||||
"P1",
|
||||
"PRD01 §4.3",
|
||||
)
|
||||
add(
|
||||
"还车应结款主流程",
|
||||
"还车应结款",
|
||||
"详情-安全部三行费项",
|
||||
"主流程 ap-14 详情",
|
||||
"1. 查看安全部分组",
|
||||
"",
|
||||
"固定三行:违章处理违约金、保险上浮、其他违规费用;含违章统计区",
|
||||
"P1",
|
||||
"PRD01 §4.3",
|
||||
)
|
||||
add(
|
||||
"还车应结款主流程",
|
||||
"还车应结款",
|
||||
"详情-业务管理组不含安全费项",
|
||||
"主流程 ap-14 详情",
|
||||
"1. 查看业务管理组分组",
|
||||
"",
|
||||
"仅 ETC、停车费等;不含违章违约金、保险上浮",
|
||||
"P1",
|
||||
"PRD01 §4.3",
|
||||
)
|
||||
add(
|
||||
"还车应结款主流程",
|
||||
"还车应结款",
|
||||
"详情-部门子流程进度",
|
||||
"主流程 ap-14 详情",
|
||||
"1. 查看 Hero 或进度区",
|
||||
"",
|
||||
"展示四部门子流程通过状态/进度",
|
||||
"P1",
|
||||
"PRD01 §4.2",
|
||||
)
|
||||
add(
|
||||
"还车应结款主流程",
|
||||
"还车应结款",
|
||||
"待办-审批通过",
|
||||
"待办 ap-14 详情",
|
||||
"1. 点击「通过」并确认",
|
||||
"",
|
||||
"框内半屏审批通过表单;确认后 toast 提示(原型)",
|
||||
"P1",
|
||||
"tc-btn-approve",
|
||||
)
|
||||
add(
|
||||
"还车应结款主流程",
|
||||
"还车应结款",
|
||||
"已完结主流程-已办",
|
||||
"Tab=我的已办",
|
||||
"1. 打开沪A03561F HC-2026-0520(ap-4)",
|
||||
"status=已通过",
|
||||
"可查看详情;Hero 展示历史应退信息;无操作栏",
|
||||
"P2",
|
||||
"ap-4 mock",
|
||||
)
|
||||
|
||||
# ========== 还车应结款四部门子流程 ==========
|
||||
depts = [
|
||||
("还车应结款-业务管理组", "业务管理组", "ETC/停车费", "ap-22", "浙F06901F", "HC-2026-0625-BS"),
|
||||
("还车应结款-能源服务组", "能源服务组", "氢量差补缴/预付款退费", "ap-23", "浙F06901F", "HC-2026-0625-EN"),
|
||||
("还车应结款-运维组", "运维组", "清洗/保养/维修/无忧包减免", "ap-24", "浙F06901F", "HC-2026-0625-OP"),
|
||||
("还车应结款-安全部", "安全部", "违章违约金/保险上浮/其他违规", "ap-25", "浙F06901F", "HC-2026-0625-SF"),
|
||||
]
|
||||
|
||||
add(
|
||||
"还车应结款子流程",
|
||||
"四部门合计",
|
||||
"场景B-四部门均在待办",
|
||||
"Tab=我的待办;用户=张明辉",
|
||||
"1. Chip「还车应结款」\n2. 筛选浙F06901F",
|
||||
"场景B / ap-22~25 均为审批中",
|
||||
"同车牌四条子流程均出现在待办;主流程 HC-2026-0625 不在列表",
|
||||
"P0",
|
||||
"PRD01 §4.1 场景B;page-audit-return",
|
||||
)
|
||||
|
||||
for flow, dept, fee_hint, ap_id, plate, biz in depts:
|
||||
add(
|
||||
"还车应结款子流程",
|
||||
flow,
|
||||
f"列表-待办卡片展示({dept})",
|
||||
"Tab=我的待办;场景B",
|
||||
f"1. 找到 {flow} 卡片",
|
||||
f"{ap_id};车牌={plate};单据={biz}",
|
||||
f"左上角标签={flow};加粗标题={plate}(车牌重点);副标题客户+项目;元信息仅发起人(不展示汇总应退/应补)",
|
||||
"P0",
|
||||
"page-audit;批注:子流程列表车牌重点",
|
||||
)
|
||||
add(
|
||||
"还车应结款子流程",
|
||||
flow,
|
||||
f"详情-Hero({dept})",
|
||||
f"待办 {ap_id}",
|
||||
"1. 进入详情\n2. 查看 Hero",
|
||||
plate,
|
||||
f"Hero 标题「{flow}」+ 车牌胶囊重点样式;不展示保证金/应退应补汇总金额",
|
||||
"P0",
|
||||
"PRD01 §4.2",
|
||||
)
|
||||
add(
|
||||
"还车应结款子流程",
|
||||
flow,
|
||||
f"详情-本部门费用({dept})",
|
||||
f"待办 {ap_id}",
|
||||
"1. 查看费用明细区块",
|
||||
fee_hint,
|
||||
f"仅展示{dept}相关费项;底部本部门应结算总额",
|
||||
"P1",
|
||||
"PRD01 §4.3",
|
||||
)
|
||||
add(
|
||||
"还车应结款子流程",
|
||||
flow,
|
||||
f"详情-状态标签审批中({dept})",
|
||||
f"待办 {ap_id};status=审批中",
|
||||
"1. 查看分组卡片右上角(若详情含)",
|
||||
"",
|
||||
"子流程详情按实际状态展示;列表卡片状态含当前节点审批人",
|
||||
"P1",
|
||||
"hc-settle-group-status",
|
||||
)
|
||||
add(
|
||||
"还车应结款子流程",
|
||||
flow,
|
||||
f"待办-审批操作({dept})",
|
||||
f"待办 {ap_id}",
|
||||
"1. 点击通过/驳回/终止/评论",
|
||||
"",
|
||||
"底部操作栏可用;弹窗限定手机框内",
|
||||
"P1",
|
||||
"tc-btn-approve",
|
||||
)
|
||||
|
||||
add(
|
||||
"还车应结款子流程",
|
||||
"四部门合计",
|
||||
"场景A-子流程均已通过",
|
||||
"Tab=我的已办或全部",
|
||||
"1. 搜索粤B58888F\n2. 查看四部门子流程",
|
||||
"ap-15/18/19/20 status=已通过",
|
||||
"四条子流程状态已通过;不在待办(currentAssignee 为空)",
|
||||
"P1",
|
||||
"场景A",
|
||||
)
|
||||
add(
|
||||
"还车应结款子流程",
|
||||
"四部门合计",
|
||||
"场景C-子流程已通过主流程未发起",
|
||||
"Tab=全部/已办",
|
||||
"1. 搜索苏E12345F",
|
||||
"ap-27~30 已通过;ap-26 待发起",
|
||||
"四部门子流程可见且已通过;主流程 ap-26 不出现在列表",
|
||||
"P0",
|
||||
"场景C",
|
||||
)
|
||||
add(
|
||||
"还车应结款子流程",
|
||||
"还车应结款-业务管理组",
|
||||
"搜索-按流程类型名称",
|
||||
"审批中心",
|
||||
"1. 搜索「业务管理组」",
|
||||
"",
|
||||
"可匹配 flowType 含业务管理组的任务(含浙F06901F 等待办)",
|
||||
"P2",
|
||||
"搜索增强:标题+flowType",
|
||||
)
|
||||
|
||||
# ========== 审批中心通用 ==========
|
||||
COMMON = [
|
||||
(
|
||||
"审批中心通用",
|
||||
"全部",
|
||||
"四 Tab 切换",
|
||||
"进入审批中心",
|
||||
"1. 依次切换我发起的/待办/已办/抄送",
|
||||
"用户=张明辉",
|
||||
"各 Tab 列表数量与 mock 过滤规则一致",
|
||||
"P1",
|
||||
"PRD01 §3.1",
|
||||
),
|
||||
(
|
||||
"审批中心通用",
|
||||
"全部",
|
||||
"更多筛选-框内 sheet",
|
||||
"审批中心",
|
||||
"1. 点击搜索框右侧筛选按钮",
|
||||
"",
|
||||
"更多筛选从手机预览框底部滑出,不铺满浏览器",
|
||||
"P1",
|
||||
"PRD01 §3.6;page-audit",
|
||||
),
|
||||
(
|
||||
"审批中心通用",
|
||||
"全部",
|
||||
"更多筛选-发起人滚轮",
|
||||
"打开更多筛选 sheet",
|
||||
"1. 点击发起人「请选择 ›」\n2. 滚轮选择并确定",
|
||||
"",
|
||||
"框内滚轮选择器;首项「不限」",
|
||||
"P1",
|
||||
"PRD01 §3.6",
|
||||
),
|
||||
(
|
||||
"审批中心通用",
|
||||
"全部",
|
||||
"更多筛选-发起日期滚轮",
|
||||
"打开更多筛选 sheet",
|
||||
"1. 点击开始/结束日期\n2. 年/月/日滚轮选择",
|
||||
"",
|
||||
"日期滚轮叠在筛选 sheet 之上;高亮条不遮挡文字",
|
||||
"P1",
|
||||
"PRD01 §3.6",
|
||||
),
|
||||
(
|
||||
"审批中心通用",
|
||||
"全部",
|
||||
"更多类型 sheet",
|
||||
"审批中心",
|
||||
"1. 点击「更多类型」Chip",
|
||||
"",
|
||||
"框内 sheet 展示全部流程类型;选择后关闭并筛选",
|
||||
"P2",
|
||||
"page-audit",
|
||||
),
|
||||
(
|
||||
"审批中心通用",
|
||||
"全部",
|
||||
"Toast 框内反馈",
|
||||
"任意触发 toast 的操作",
|
||||
"1. 如筛选确定、审批确认",
|
||||
"",
|
||||
"提示出现在手机预览框顶部,非浏览器全局 message",
|
||||
"P2",
|
||||
"UI Review P2 修复",
|
||||
),
|
||||
]
|
||||
for c in COMMON:
|
||||
add(*c)
|
||||
|
||||
# Renumber
|
||||
for i, row in enumerate(CASES, 1):
|
||||
module = row[0]
|
||||
prefix = {"提车应收款": "TC", "还车应结款主流程": "RS-M", "还车应结款子流程": "RS-S", "审批中心通用": "AC"}.get(module, "AC")
|
||||
row.insert(0, f"{prefix}-{i:03d}")
|
||||
|
||||
wb = Workbook()
|
||||
meta = wb.active
|
||||
meta.title = "说明"
|
||||
meta["A1"] = "小羚羚小程序 · 审批中心测试用例"
|
||||
meta["A1"].font = Font(bold=True, size=14)
|
||||
meta["A2"] = f"生成日期:{date.today().isoformat()}"
|
||||
meta["A3"] = "范围:提车应收款、还车应结款主流程、还车应结款四部门子流程"
|
||||
meta["A4"] = "默认测试用户:张明辉(MOCK_USER)"
|
||||
meta["A5"] = "预览入口:http://localhost:51721/prototypes/xll-miniapp#page=audit"
|
||||
meta["A6"] = "数据来源:annotation-source.json + PRD01 V1.4 + index.tsx mock"
|
||||
meta.column_dimensions["A"].width = 80
|
||||
|
||||
for title, rows in [
|
||||
("提车应收款", [r for r in CASES if r[1] == "提车应收款"]),
|
||||
("还车应结款-主流程", [r for r in CASES if r[1] == "还车应结款主流程"]),
|
||||
("还车应结款-子流程", [r for r in CASES if r[1] == "还车应结款子流程"]),
|
||||
("审批中心通用", [r for r in CASES if r[1] == "审批中心通用"]),
|
||||
("全部用例", CASES),
|
||||
]:
|
||||
ws = wb.create_sheet(title[:31])
|
||||
header_fill = PatternFill("solid", fgColor="7AB929")
|
||||
header_font = Font(bold=True, color="FFFFFF")
|
||||
thin = Side(style="thin", color="E5E6EB")
|
||||
border = Border(left=thin, right=thin, top=thin, bottom=thin)
|
||||
ws.append(HEADERS)
|
||||
for col in range(1, len(HEADERS) + 1):
|
||||
cell = ws.cell(row=1, column=col)
|
||||
cell.fill = header_fill
|
||||
cell.font = header_font
|
||||
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
|
||||
cell.border = border
|
||||
for row in rows:
|
||||
ws.append(row)
|
||||
widths = [12, 14, 22, 24, 28, 36, 28, 40, 8, 24]
|
||||
for i, w in enumerate(widths, 1):
|
||||
ws.column_dimensions[get_column_letter(i)].width = w
|
||||
for r in range(2, ws.max_row + 1):
|
||||
for c in range(1, len(HEADERS) + 1):
|
||||
ws.cell(row=r, column=c).alignment = Alignment(vertical="top", wrap_text=True)
|
||||
ws.cell(row=r, column=c).border = border
|
||||
ws.freeze_panes = "A2"
|
||||
|
||||
wb.save(OUTPUT)
|
||||
print(OUTPUT)
|
||||
print(f"cases={len(CASES)}")
|
||||
18
scripts/scan-entries.js
Normal file
18
scripts/scan-entries.js
Normal 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, ['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)',
|
||||
);
|
||||
68
scripts/smoke-preview-routes.mjs
Normal file
68
scripts/smoke-preview-routes.mjs
Normal file
@@ -0,0 +1,68 @@
|
||||
#!/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',
|
||||
'/prototypes/ref-app-home',
|
||||
'/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 loaderProxy = htmlProxyMatches.find((value) => value.includes('index=0.js')) || htmlProxyMatches[0] || null;
|
||||
|
||||
let loaderScript = '';
|
||||
if (loaderProxy) {
|
||||
loaderScript = await fetch(new URL(loaderProxy, baseUrl)).then((res) => res.text());
|
||||
}
|
||||
|
||||
const ok = response.ok
|
||||
&& html.includes('<div id="root"></div>')
|
||||
&& htmlProxyMatches.length >= 1
|
||||
&& !html.includes('waitForBootstrap')
|
||||
&& loaderScript.includes('import PreviewComponent from')
|
||||
&& loaderScript.includes('import.meta.hot.accept(')
|
||||
&& 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(` loaderProxy=${Boolean(loaderProxy)}`);
|
||||
console.error(` loaderImportsEntry=${loaderScript.includes('import PreviewComponent from')}`);
|
||||
console.error(` loaderHasAcceptBoundary=${loaderScript.includes('import.meta.hot.accept(')}`);
|
||||
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;
|
||||
}
|
||||
114
scripts/stitch-converter.mjs
Normal file
114
scripts/stitch-converter.mjs
Normal file
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
function normalizeSlashes(input) {
|
||||
return String(input || '').replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
function sanitizeName(rawName) {
|
||||
return String(rawName || '')
|
||||
.replace(/[^a-z0-9-]/gi, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = [...argv];
|
||||
const projectDirArg = args.shift();
|
||||
const outputNameArg = args.shift();
|
||||
let projectRoot = process.cwd();
|
||||
let outputBaseDir = '';
|
||||
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
if (arg === '--project-root') {
|
||||
projectRoot = path.resolve(args[index + 1] || projectRoot);
|
||||
index += 1;
|
||||
} else if (arg === '--output-base-dir') {
|
||||
outputBaseDir = path.resolve(args[index + 1] || '');
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (!projectDirArg) throw new Error('Missing Stitch directory');
|
||||
const outputName = sanitizeName(outputNameArg || path.basename(projectDirArg));
|
||||
if (!outputName) throw new Error('Missing valid output name');
|
||||
return {
|
||||
stitchDir: path.resolve(projectRoot, projectDirArg),
|
||||
outputName,
|
||||
projectRoot,
|
||||
outputBaseDir: outputBaseDir || path.resolve(projectRoot, 'src/prototypes'),
|
||||
};
|
||||
}
|
||||
|
||||
function extractBody(html) {
|
||||
const match = html.match(/<body[^>]*>([\s\S]*?)<\/body>/iu);
|
||||
return match?.[1]?.trim() || html;
|
||||
}
|
||||
|
||||
function hasPendingLogic(html) {
|
||||
return /<script\b|on[A-Z]?\w+=/iu.test(html);
|
||||
}
|
||||
|
||||
function escapeTemplate(value) {
|
||||
return String(value).replace(/`/g, '\\`').replace(/\$\{/g, '\\${');
|
||||
}
|
||||
|
||||
function findCodeHtml(stitchDir) {
|
||||
const direct = path.join(stitchDir, 'code.html');
|
||||
if (fs.existsSync(direct)) return direct;
|
||||
for (const entry of fs.readdirSync(stitchDir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const candidate = path.join(stitchDir, entry.name, 'code.html');
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
}
|
||||
throw new Error('未找到有效的 Stitch 项目结构');
|
||||
}
|
||||
|
||||
function main() {
|
||||
const parsed = parseArgs(process.argv.slice(2));
|
||||
const codePath = findCodeHtml(parsed.stitchDir);
|
||||
const html = fs.readFileSync(codePath, 'utf8');
|
||||
const outputDir = path.join(parsed.outputBaseDir, parsed.outputName);
|
||||
fs.rmSync(outputDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
const body = extractBody(html);
|
||||
fs.writeFileSync(path.join(outputDir, 'index.tsx'), [
|
||||
"import React from 'react';",
|
||||
"import './style.css';",
|
||||
'',
|
||||
'export default function StitchPrototype() {',
|
||||
' return <div className="stitch-import" dangerouslySetInnerHTML={{ __html: markup }} />;',
|
||||
'}',
|
||||
'',
|
||||
`const markup = \`${escapeTemplate(body)}\`;`,
|
||||
'',
|
||||
].join('\n'), 'utf8');
|
||||
fs.writeFileSync(path.join(outputDir, 'style.css'), '.stitch-import { min-height: 100%; }\n', 'utf8');
|
||||
const requiresAi = hasPendingLogic(html);
|
||||
const prompt = requiresAi
|
||||
? [
|
||||
'Google Stitch 页面已导入完成,但检测到脚本或事件逻辑需要 AI 继续完善。',
|
||||
'',
|
||||
`请读取输出目录:\`${normalizeSlashes(path.relative(parsed.projectRoot, outputDir))}/\``,
|
||||
'请先参考 `rules/development-guide.md` 和 `rules/design-guide.md`,再完善交互与动态逻辑。',
|
||||
].join('\n')
|
||||
: null;
|
||||
console.log(JSON.stringify({
|
||||
success: true,
|
||||
outputDir,
|
||||
requiresAi,
|
||||
prompt,
|
||||
reasons: requiresAi ? ['检测到脚本或内联事件逻辑'] : [],
|
||||
}));
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
console.error(error?.message || String(error));
|
||||
process.exit(1);
|
||||
}
|
||||
268
scripts/subset-beginner-guide-fonts.mjs
Normal file
268
scripts/subset-beginner-guide-fonts.mjs
Normal file
@@ -0,0 +1,268 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { TextDecoder } from 'node:util';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const DEFAULT_APP_ROOT = path.resolve(__dirname, '..');
|
||||
|
||||
const SOURCE_FILES = [
|
||||
'index.tsx',
|
||||
'style.css',
|
||||
'index.html',
|
||||
'README.md',
|
||||
];
|
||||
|
||||
const EXTRA_CODE_POINTS = [
|
||||
0x00a0,
|
||||
0x2013,
|
||||
0x2014,
|
||||
0x2018,
|
||||
0x2019,
|
||||
0x201c,
|
||||
0x201d,
|
||||
0x2022,
|
||||
0x2026,
|
||||
0x3000,
|
||||
0x3001,
|
||||
0x3002,
|
||||
0x300a,
|
||||
0x300b,
|
||||
0x300c,
|
||||
0x300d,
|
||||
0xff01,
|
||||
0xff08,
|
||||
0xff09,
|
||||
0xff0c,
|
||||
0xff1a,
|
||||
0xff1b,
|
||||
0xff1f,
|
||||
];
|
||||
|
||||
const ASCII_FALLBACK =
|
||||
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' +
|
||||
' ~`!@#$%^&*()-_=+[]{}\\|;:\'",.<>/?';
|
||||
|
||||
function uniqueCharacters(input) {
|
||||
const seen = new Set();
|
||||
const output = [];
|
||||
for (const character of input) {
|
||||
if (!seen.has(character)) {
|
||||
seen.add(character);
|
||||
output.push(character);
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
export function parseSubsetArgs(argv = process.argv) {
|
||||
const args = {
|
||||
appRoot: DEFAULT_APP_ROOT,
|
||||
dryRun: false,
|
||||
sourceFontDir: process.env.AXHUB_BEGINNER_GUIDE_FONT_SOURCE_DIR || '',
|
||||
};
|
||||
|
||||
const values = argv.slice(2);
|
||||
for (let index = 0; index < values.length; index += 1) {
|
||||
const value = values[index];
|
||||
if (value === '--dry-run') {
|
||||
args.dryRun = true;
|
||||
continue;
|
||||
}
|
||||
if (value === '--app-root') {
|
||||
args.appRoot = path.resolve(values[index + 1] || '');
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (value === '--source-font-dir') {
|
||||
args.sourceFontDir = path.resolve(values[index + 1] || '');
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (value === '--help' || value === '-h') {
|
||||
args.help = true;
|
||||
continue;
|
||||
}
|
||||
throw new Error(`Unknown option: ${value}`);
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
export function resolveBeginnerGuidePaths(options = {}) {
|
||||
const appRoot = path.resolve(options.appRoot || DEFAULT_APP_ROOT);
|
||||
const prototypeRoot = path.join(appRoot, 'src/prototypes/beginner-guide');
|
||||
const localSourceFontDir = path.join(appRoot, '.local/font-sources/beginner-guide');
|
||||
const sourceFontDir = options.sourceFontDir
|
||||
? path.resolve(options.sourceFontDir)
|
||||
: fs.existsSync(localSourceFontDir)
|
||||
? localSourceFontDir
|
||||
: prototypeRoot;
|
||||
|
||||
return {
|
||||
appRoot,
|
||||
prototypeRoot,
|
||||
sourceFontDir,
|
||||
};
|
||||
}
|
||||
|
||||
export function collectCharactersFromFiles(filePaths) {
|
||||
const content = filePaths
|
||||
.filter((filePath) => fs.existsSync(filePath))
|
||||
.map((filePath) => fs.readFileSync(filePath, 'utf8'))
|
||||
.join('\n');
|
||||
|
||||
return uniqueCharacters(content);
|
||||
}
|
||||
|
||||
export function getGb2312LevelOneCharacters() {
|
||||
const decoder = new TextDecoder('gb18030');
|
||||
const characters = [];
|
||||
|
||||
for (let high = 0xb0; high <= 0xd7; high += 1) {
|
||||
for (let low = 0xa1; low <= 0xfe; low += 1) {
|
||||
characters.push(decoder.decode(Buffer.from([high, low])));
|
||||
}
|
||||
}
|
||||
|
||||
return uniqueCharacters(characters).join('');
|
||||
}
|
||||
|
||||
export function collectSubsetCharacters(options = {}) {
|
||||
const pageCharacters = Array.isArray(options.pageCharacters)
|
||||
? options.pageCharacters
|
||||
: [...String(options.pageCharacters || '')];
|
||||
const commonCharacters = String(
|
||||
options.commonCharacters ?? getGb2312LevelOneCharacters(),
|
||||
);
|
||||
const extraCharacters = String(
|
||||
options.extraCharacters
|
||||
?? `${ASCII_FALLBACK}${String.fromCodePoint(...EXTRA_CODE_POINTS)}`,
|
||||
);
|
||||
|
||||
return uniqueCharacters(`${commonCharacters}${extraCharacters}${pageCharacters.join('')}`);
|
||||
}
|
||||
|
||||
export function getFontJobs(paths) {
|
||||
return [
|
||||
{
|
||||
weight: 400,
|
||||
inputPath: path.join(paths.sourceFontDir, 'TsangerJinKai02-W04.ttf'),
|
||||
outputPath: path.join(paths.prototypeRoot, 'TsangerJinKai02-W04.subset.woff2'),
|
||||
},
|
||||
{
|
||||
weight: 500,
|
||||
inputPath: path.join(paths.sourceFontDir, 'TsangerJinKai02-W05.ttf'),
|
||||
outputPath: path.join(paths.prototypeRoot, 'TsangerJinKai02-W05.subset.woff2'),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function getSourceFilePaths(paths) {
|
||||
return SOURCE_FILES.map((fileName) => path.join(paths.prototypeRoot, fileName));
|
||||
}
|
||||
|
||||
function formatSize(byteLength) {
|
||||
if (byteLength < 1024) return `${byteLength} B`;
|
||||
if (byteLength < 1024 * 1024) return `${(byteLength / 1024).toFixed(1)} KB`;
|
||||
return `${(byteLength / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`Usage: node scripts/subset-beginner-guide-fonts.mjs [options]
|
||||
|
||||
Options:
|
||||
--dry-run Print the plan without writing fonts.
|
||||
--app-root <path> Client app root. Defaults to the current client.
|
||||
--source-font-dir <path> Directory containing TsangerJinKai02-W04.ttf and W05.ttf.
|
||||
|
||||
Environment:
|
||||
AXHUB_BEGINNER_GUIDE_FONT_SOURCE_DIR can provide the source font directory.
|
||||
`);
|
||||
}
|
||||
|
||||
export async function subsetBeginnerGuideFonts(options = {}) {
|
||||
const paths = resolveBeginnerGuidePaths(options);
|
||||
const sourceFilePaths = getSourceFilePaths(paths);
|
||||
const pageCharacters = collectCharactersFromFiles(sourceFilePaths);
|
||||
const subsetCharacters = collectSubsetCharacters({ pageCharacters });
|
||||
const jobs = getFontJobs(paths);
|
||||
|
||||
if (options.dryRun) {
|
||||
return {
|
||||
paths,
|
||||
jobs,
|
||||
characterCount: subsetCharacters.length,
|
||||
written: [],
|
||||
};
|
||||
}
|
||||
|
||||
const missingInputs = jobs
|
||||
.map((job) => job.inputPath)
|
||||
.filter((inputPath) => !fs.existsSync(inputPath));
|
||||
if (missingInputs.length > 0) {
|
||||
throw new Error(
|
||||
[
|
||||
'Missing source font file(s):',
|
||||
...missingInputs.map((inputPath) => ` - ${inputPath}`),
|
||||
'Pass --source-font-dir or set AXHUB_BEGINNER_GUIDE_FONT_SOURCE_DIR.',
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
const { default: subsetFont } = await import('subset-font');
|
||||
fs.mkdirSync(paths.prototypeRoot, { recursive: true });
|
||||
|
||||
const written = [];
|
||||
for (const job of jobs) {
|
||||
const inputBuffer = fs.readFileSync(job.inputPath);
|
||||
const outputBuffer = await subsetFont(inputBuffer, subsetCharacters.join(''), {
|
||||
targetFormat: 'woff2',
|
||||
preserveNameIds: [1, 2, 4, 6],
|
||||
});
|
||||
fs.writeFileSync(job.outputPath, outputBuffer);
|
||||
written.push({
|
||||
...job,
|
||||
inputSize: inputBuffer.byteLength,
|
||||
outputSize: outputBuffer.byteLength,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
paths,
|
||||
jobs,
|
||||
characterCount: subsetCharacters.length,
|
||||
written,
|
||||
};
|
||||
}
|
||||
|
||||
async function runCli() {
|
||||
const args = parseSubsetArgs(process.argv);
|
||||
if (args.help) {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await subsetBeginnerGuideFonts(args);
|
||||
console.log(`Beginner guide subset characters: ${result.characterCount}`);
|
||||
for (const job of result.jobs) {
|
||||
if (args.dryRun) {
|
||||
console.log(`dry-run ${job.weight}: ${job.inputPath} -> ${job.outputPath}`);
|
||||
continue;
|
||||
}
|
||||
const written = result.written.find((item) => item.weight === job.weight);
|
||||
console.log(
|
||||
`${job.weight}: ${formatSize(written.inputSize)} -> ${formatSize(written.outputSize)} ${written.outputPath}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === __filename) {
|
||||
runCli().catch((error) => {
|
||||
console.error(error.message || error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
89
scripts/subset-beginner-guide-fonts.test.mjs
Normal file
89
scripts/subset-beginner-guide-fonts.test.mjs
Normal file
@@ -0,0 +1,89 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
collectCharactersFromFiles,
|
||||
collectSubsetCharacters,
|
||||
getFontJobs,
|
||||
parseSubsetArgs,
|
||||
resolveBeginnerGuidePaths,
|
||||
} from './subset-beginner-guide-fonts.mjs';
|
||||
|
||||
describe('beginner guide font subsetting', () => {
|
||||
it('collects unique page characters while ignoring duplicate text', () => {
|
||||
const fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), 'beginner-font-subset-'));
|
||||
const sourcePath = path.join(fixtureDir, 'index.tsx');
|
||||
fs.writeFileSync(sourcePath, '<h1>发布到云端服务</h1><p>发布到云端服务 ABC 123</p>');
|
||||
|
||||
const characters = collectCharactersFromFiles([sourcePath]);
|
||||
|
||||
expect(characters).toContain('发');
|
||||
expect(characters).toContain('务');
|
||||
expect(characters).toContain('A');
|
||||
expect(characters.filter((character) => character === '发')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('merges page text with a reusable common Chinese fallback set', () => {
|
||||
const characters = collectSubsetCharacters({
|
||||
pageCharacters: ['专', '属', 'A'],
|
||||
commonCharacters: '的一是专',
|
||||
extraCharacters: ' A',
|
||||
});
|
||||
|
||||
expect(characters.join('')).toContain('专');
|
||||
expect(characters.join('')).toContain('的');
|
||||
expect(characters.join('')).toContain('A');
|
||||
expect(characters.filter((character) => character === '专')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('builds one subsetting job for each TsangerJinKai02 source weight', () => {
|
||||
const appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'beginner-font-app-'));
|
||||
const prototypeRoot = path.join(appRoot, 'src/prototypes/beginner-guide');
|
||||
fs.mkdirSync(prototypeRoot, { recursive: true });
|
||||
|
||||
const paths = resolveBeginnerGuidePaths({ appRoot });
|
||||
const jobs = getFontJobs(paths);
|
||||
|
||||
expect(jobs).toEqual([
|
||||
{
|
||||
weight: 400,
|
||||
inputPath: path.join(prototypeRoot, 'TsangerJinKai02-W04.ttf'),
|
||||
outputPath: path.join(prototypeRoot, 'TsangerJinKai02-W04.subset.woff2'),
|
||||
},
|
||||
{
|
||||
weight: 500,
|
||||
inputPath: path.join(prototypeRoot, 'TsangerJinKai02-W05.ttf'),
|
||||
outputPath: path.join(prototypeRoot, 'TsangerJinKai02-W05.subset.woff2'),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('prefers ignored local source fonts over published prototype files', () => {
|
||||
const appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'beginner-font-local-'));
|
||||
const localSourceRoot = path.join(appRoot, '.local/font-sources/beginner-guide');
|
||||
fs.mkdirSync(localSourceRoot, { recursive: true });
|
||||
|
||||
const paths = resolveBeginnerGuidePaths({ appRoot });
|
||||
|
||||
expect(paths.sourceFontDir).toBe(localSourceRoot);
|
||||
});
|
||||
|
||||
it('parses script options for dry runs and explicit source font directories', () => {
|
||||
const args = parseSubsetArgs([
|
||||
'node',
|
||||
'subset-beginner-guide-fonts.mjs',
|
||||
'--dry-run',
|
||||
'--source-font-dir',
|
||||
'/tmp/fonts',
|
||||
'--app-root',
|
||||
'/tmp/app',
|
||||
]);
|
||||
|
||||
expect(args.dryRun).toBe(true);
|
||||
expect(args.sourceFontDir).toBe('/tmp/fonts');
|
||||
expect(args.appRoot).toBe('/tmp/app');
|
||||
});
|
||||
});
|
||||
305
scripts/sync-annotation-directory.mjs
Normal file
305
scripts/sync-annotation-directory.mjs
Normal file
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* 将 index.tsx 中的 defineHashPageRoute 页面同步到 annotation-source.json 的 directory.route 节点。
|
||||
*
|
||||
* 用法:
|
||||
* node scripts/sync-annotation-directory.mjs
|
||||
* node scripts/sync-annotation-directory.mjs --prototype xll-miniapp
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import ts from 'typescript';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PAGE_ID_RE = /^[a-z0-9-]+$/u;
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = { prototype: '', all: false, projectRoot: '' };
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
if (arg === '--prototype' && argv[i + 1]) {
|
||||
args.prototype = argv[++i].trim();
|
||||
} else if (arg === '--project-root' && argv[i + 1]) {
|
||||
args.projectRoot = path.resolve(argv[++i].trim());
|
||||
} else if (arg === '--all') {
|
||||
args.all = true;
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function readJson(filePath) {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
}
|
||||
|
||||
function writeJson(filePath, value) {
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
|
||||
function normalizePageId(value) {
|
||||
const id = typeof value === 'string' ? value.trim() : '';
|
||||
return PAGE_ID_RE.test(id) ? id : '';
|
||||
}
|
||||
|
||||
function getLiteralPropertyValue(objectLiteral, propertyName) {
|
||||
const property = objectLiteral.properties.find((candidate) => (
|
||||
ts.isPropertyAssignment(candidate)
|
||||
&& (
|
||||
(ts.isIdentifier(candidate.name) && candidate.name.text === propertyName)
|
||||
|| (ts.isStringLiteral(candidate.name) && candidate.name.text === propertyName)
|
||||
)
|
||||
));
|
||||
if (!property || !ts.isPropertyAssignment(property)) {
|
||||
return null;
|
||||
}
|
||||
const initializer = property.initializer;
|
||||
return ts.isStringLiteral(initializer) || ts.isNoSubstitutionTemplateLiteral(initializer)
|
||||
? initializer.text.trim()
|
||||
: null;
|
||||
}
|
||||
|
||||
function extractHashRouteFromCall(callExpression) {
|
||||
const expression = callExpression.expression;
|
||||
if (!ts.isIdentifier(expression) || expression.text !== 'defineHashPageRoute') {
|
||||
return null;
|
||||
}
|
||||
const pagesArg = callExpression.arguments[0];
|
||||
if (!pagesArg || !ts.isArrayLiteralExpression(pagesArg)) {
|
||||
return null;
|
||||
}
|
||||
const pages = [];
|
||||
for (const element of pagesArg.elements) {
|
||||
if (!ts.isObjectLiteralExpression(element)) continue;
|
||||
const id = normalizePageId(getLiteralPropertyValue(element, 'id'));
|
||||
const title = getLiteralPropertyValue(element, 'title');
|
||||
if (id && title) pages.push({ id, title });
|
||||
}
|
||||
if (!pages.length) return null;
|
||||
const optionsArg = callExpression.arguments[1];
|
||||
const requestedDefaultPageId = optionsArg && ts.isObjectLiteralExpression(optionsArg)
|
||||
? normalizePageId(getLiteralPropertyValue(optionsArg, 'defaultPageId'))
|
||||
: '';
|
||||
const defaultPageId = pages.some((page) => page.id === requestedDefaultPageId)
|
||||
? requestedDefaultPageId
|
||||
: pages[0].id;
|
||||
return { pages, defaultPageId };
|
||||
}
|
||||
|
||||
function extractHashRouteFromPrototype(prototypeDir) {
|
||||
const indexFile = path.join(prototypeDir, 'index.tsx');
|
||||
if (!fs.existsSync(indexFile)) return null;
|
||||
const sourceText = fs.readFileSync(indexFile, 'utf8');
|
||||
const sourceFile = ts.createSourceFile(indexFile, sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
||||
let route = null;
|
||||
const visit = (node) => {
|
||||
if (route) return;
|
||||
if (ts.isCallExpression(node)) {
|
||||
route = extractHashRouteFromCall(node);
|
||||
if (route) return;
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
visit(sourceFile);
|
||||
return route;
|
||||
}
|
||||
|
||||
function loadDirectoryPagesConfig(prototypeDir) {
|
||||
const configPath = path.join(prototypeDir, 'directory.pages.json');
|
||||
if (!fs.existsSync(configPath)) return null;
|
||||
return readJson(configPath);
|
||||
}
|
||||
|
||||
function routeNodeId(route) {
|
||||
return `route-${String(route).replace(/[^a-z0-9]+/gi, '-').replace(/^-+|-+$/g, '').toLowerCase()}`;
|
||||
}
|
||||
|
||||
function createRouteNode(route, title) {
|
||||
return {
|
||||
type: 'route',
|
||||
id: routeNodeId(route),
|
||||
title,
|
||||
route,
|
||||
};
|
||||
}
|
||||
|
||||
function collectRouteNodes(nodes, result = []) {
|
||||
for (const node of nodes || []) {
|
||||
if (node?.type === 'route') result.push(node);
|
||||
if (node?.type === 'folder' && Array.isArray(node.children)) {
|
||||
collectRouteNodes(node.children, result);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function resolveRouteToken(pageId, existingRoutes) {
|
||||
const byId = existingRoutes.find((node) => node.id === routeNodeId(pageId));
|
||||
if (byId) return byId.route;
|
||||
const camel = pageId.replace(/-([a-z])/g, (_, ch) => ch.toUpperCase());
|
||||
const byCamel = existingRoutes.find((node) => node.route === camel);
|
||||
if (byCamel) return byCamel.route;
|
||||
return pageId;
|
||||
}
|
||||
|
||||
function buildRouteFolders(pages, config, existingDirectory) {
|
||||
const existingRoutes = collectRouteNodes(existingDirectory?.nodes);
|
||||
const extraRoutes = Array.isArray(config?.extraRoutes) ? config.extraRoutes : [];
|
||||
const groups = Array.isArray(config?.groups) && config.groups.length
|
||||
? config.groups
|
||||
: [{
|
||||
folderId: 'directory-pages',
|
||||
folderTitle: '页面导航',
|
||||
defaultExpanded: true,
|
||||
pageIds: pages.map((page) => page.id),
|
||||
}];
|
||||
|
||||
const assigned = new Set();
|
||||
const folders = [];
|
||||
|
||||
for (const group of groups) {
|
||||
const pageIds = Array.isArray(group.pageIds) ? group.pageIds : [];
|
||||
const children = [];
|
||||
for (const pageId of pageIds) {
|
||||
const page = pages.find((item) => item.id === pageId);
|
||||
if (!page) continue;
|
||||
assigned.add(page.id);
|
||||
children.push(createRouteNode(resolveRouteToken(page.id, existingRoutes), page.title));
|
||||
}
|
||||
for (const extra of extraRoutes) {
|
||||
if (extra.folderId !== group.folderId) continue;
|
||||
const route = String(extra.route || '').trim();
|
||||
const title = String(extra.title || '').trim();
|
||||
if (!route || !title) continue;
|
||||
children.push(createRouteNode(route, title));
|
||||
assigned.add(`extra:${route}`);
|
||||
}
|
||||
if (!children.length) continue;
|
||||
folders.push({
|
||||
type: 'folder',
|
||||
id: group.folderId || `directory-group-${folders.length + 1}`,
|
||||
title: group.folderTitle || '页面',
|
||||
defaultExpanded: group.defaultExpanded !== false,
|
||||
children,
|
||||
});
|
||||
}
|
||||
|
||||
const unassigned = pages.filter((page) => !assigned.has(page.id));
|
||||
if (unassigned.length) {
|
||||
folders.push({
|
||||
type: 'folder',
|
||||
id: 'directory-pages-extra',
|
||||
title: '其他页面',
|
||||
defaultExpanded: true,
|
||||
children: unassigned.map((page) => createRouteNode(resolveRouteToken(page.id, existingRoutes), page.title)),
|
||||
});
|
||||
}
|
||||
|
||||
const coveredExtras = new Set(
|
||||
extraRoutes.filter((extra) => groups.some((group) => group.folderId === extra.folderId)).map((extra) => extra.route),
|
||||
);
|
||||
const orphanExtras = extraRoutes.filter((extra) => !coveredExtras.has(extra.route));
|
||||
if (orphanExtras.length) {
|
||||
folders.push({
|
||||
type: 'folder',
|
||||
id: 'directory-routes-extra',
|
||||
title: '补充入口',
|
||||
defaultExpanded: true,
|
||||
children: orphanExtras.map((extra) => createRouteNode(extra.route, extra.title)),
|
||||
});
|
||||
}
|
||||
|
||||
return folders;
|
||||
}
|
||||
|
||||
function preserveFolders(directory, preserveFolderIds) {
|
||||
const ids = new Set(preserveFolderIds || []);
|
||||
return (directory?.nodes || []).filter((node) => node?.type === 'folder' && ids.has(node.id));
|
||||
}
|
||||
|
||||
function fixPublishedLinks(nodes, prototypeId) {
|
||||
const walk = (items) => {
|
||||
for (const node of items || []) {
|
||||
if (node?.type === 'link' && typeof node.href === 'string') {
|
||||
const href = node.href.trim();
|
||||
const selfMatch = href.match(new RegExp(`^/prototypes/${prototypeId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:[#?].*)?$`, 'u'));
|
||||
if (selfMatch) {
|
||||
node.href = './index.html';
|
||||
node.target = node.target || 'self';
|
||||
} else {
|
||||
const otherMatch = href.match(/^\/prototypes\/([^/?#]+)(?:[#?].*)?$/u);
|
||||
if (otherMatch) {
|
||||
node.href = `../${otherMatch[1]}/index.html`;
|
||||
node.target = node.target || 'self';
|
||||
}
|
||||
}
|
||||
}
|
||||
if (node?.type === 'folder' && Array.isArray(node.children)) {
|
||||
walk(node.children);
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(nodes);
|
||||
}
|
||||
|
||||
function syncPrototypeDirectory(prototypeId, projectRoot) {
|
||||
const prototypesRoot = path.join(projectRoot, 'src/prototypes');
|
||||
const prototypeDir = path.join(prototypesRoot, prototypeId);
|
||||
const annotationPath = path.join(prototypeDir, 'annotation-source.json');
|
||||
if (!fs.existsSync(annotationPath)) {
|
||||
console.log(`[skip] ${prototypeId}: 无 annotation-source.json`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const annotation = readJson(annotationPath);
|
||||
const route = extractHashRouteFromPrototype(prototypeDir);
|
||||
const config = loadDirectoryPagesConfig(prototypeDir);
|
||||
const preserveFolderIds = config?.preserveFolderIds || ['directory-docs'];
|
||||
const preserved = preserveFolders(annotation.directory, preserveFolderIds);
|
||||
|
||||
if (route?.pages?.length) {
|
||||
const routeFolders = buildRouteFolders(route.pages, config, annotation.directory);
|
||||
annotation.directory = {
|
||||
nodes: [...routeFolders, ...preserved],
|
||||
};
|
||||
annotation.data = annotation.data || {};
|
||||
annotation.data.pageId = route.defaultPageId;
|
||||
annotation.data.updatedAt = Date.now();
|
||||
console.log(`[sync] ${prototypeId}: 已同步 ${route.pages.length} 个页面到原型目录`);
|
||||
} else {
|
||||
console.log(`[keep] ${prototypeId}: 无 hash 路由,仅修复发布链接`);
|
||||
}
|
||||
|
||||
fixPublishedLinks(annotation.directory?.nodes, prototypeId);
|
||||
writeJson(annotationPath, annotation);
|
||||
return true;
|
||||
}
|
||||
|
||||
function listPrototypeIds(projectRoot) {
|
||||
const prototypesRoot = path.join(projectRoot, 'src/prototypes');
|
||||
if (!fs.existsSync(prototypesRoot)) return [];
|
||||
return fs.readdirSync(prototypesRoot, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory() && fs.existsSync(path.join(prototypesRoot, entry.name, 'annotation-source.json')))
|
||||
.map((entry) => entry.name)
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const projectRoot = args.projectRoot || path.resolve(__dirname, '..');
|
||||
const targets = args.prototype
|
||||
? [args.prototype]
|
||||
: listPrototypeIds(projectRoot);
|
||||
|
||||
if (!targets.length) {
|
||||
console.log('未找到带 annotation-source.json 的原型。');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
let changed = 0;
|
||||
for (const prototypeId of targets) {
|
||||
if (syncPrototypeDirectory(prototypeId, projectRoot)) changed += 1;
|
||||
}
|
||||
console.log(`完成:处理 ${targets.length} 个原型,更新 ${changed} 个 annotation-source.json`);
|
||||
}
|
||||
|
||||
main();
|
||||
32
scripts/sync-project-metadata.d.ts
vendored
Normal file
32
scripts/sync-project-metadata.d.ts
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
export const PROJECT_ID: string;
|
||||
export const PROJECT_NAME: string;
|
||||
export const PRODUCT_NAME: string;
|
||||
export const DEFAULT_CLIENT_ORIGIN: string;
|
||||
export const DETERMINISTIC_UPDATED_AT: string;
|
||||
export const resourceLayout: Record<string, string[]>;
|
||||
|
||||
export function normalizeMakeClientProjectIdentity(project: unknown): {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export function readMakeClientProjectIdentity(projectRoot: string): {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export function buildMakeProjectMetadata(projectRoot: string, options?: {
|
||||
clientOrigin?: string;
|
||||
includeAbsoluteFilePaths?: boolean;
|
||||
includeRuntimeArtifacts?: boolean;
|
||||
}): any;
|
||||
export function resolveClientOrigin(projectRoot: string, fallbackOrigin?: string): string;
|
||||
export function syncMakeProjectMetadata(projectRoot: string, options?: {
|
||||
clientOrigin?: string;
|
||||
includeRuntimeUrls?: boolean;
|
||||
includeAbsoluteFilePaths?: boolean;
|
||||
includeRuntimeArtifacts?: boolean;
|
||||
}): {
|
||||
metadata: any;
|
||||
metadataPath: string;
|
||||
};
|
||||
633
scripts/sync-project-metadata.mjs
Normal file
633
scripts/sync-project-metadata.mjs
Normal file
@@ -0,0 +1,633 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import ts from 'typescript';
|
||||
|
||||
import { readServerInfo } from './utils/serverInfo.mjs';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
export const PROJECT_ID = 'make-project';
|
||||
export const PROJECT_NAME = '';
|
||||
export const PRODUCT_NAME = 'Axhub Make';
|
||||
export const MAKE_CLIENT_MARKER_KIND = 'axhub-make-client';
|
||||
export const MAKE_CLIENT_MARKER_RELATIVE_PATH = '.axhub/make/client.json';
|
||||
export const DEFAULT_CLIENT_ORIGIN = 'http://localhost:51720';
|
||||
export const DETERMINISTIC_UPDATED_AT = '2026-05-03T00:00:00.000Z';
|
||||
|
||||
export const resourceLayout = {
|
||||
prototypes: ['src/prototypes'],
|
||||
docs: ['src/resources'],
|
||||
themes: ['src/themes'],
|
||||
media: ['src/resources/assets'],
|
||||
};
|
||||
|
||||
export const resourceWriteTargets = {
|
||||
prototypes: { type: 'project-relative-path', path: resourceLayout.prototypes[0] },
|
||||
docs: { type: 'project-relative-path', path: resourceLayout.docs[0] },
|
||||
themes: { type: 'project-relative-path', path: resourceLayout.themes[0] },
|
||||
media: { type: 'project-relative-path', path: resourceLayout.media[0] },
|
||||
};
|
||||
|
||||
export const localExportCapabilities = {
|
||||
html: false,
|
||||
make: false,
|
||||
};
|
||||
|
||||
// Snapshot from https://getdesign.md/api/cli/downloads?brands=...
|
||||
const GETDESIGN_DOWNLOAD_SNAPSHOT_DATE = '2026-05-15';
|
||||
const GETDESIGN_THEME_STATS_BY_ID = {
|
||||
'airbnb': { sourceSlug: 'airbnb', downloads: 7111 },
|
||||
'airtable': { sourceSlug: 'airtable', downloads: 3446 },
|
||||
'apple': { sourceSlug: 'apple', downloads: 13995 },
|
||||
'binance': { sourceSlug: 'binance', downloads: 2179 },
|
||||
'bmw': { sourceSlug: 'bmw', downloads: 2917 },
|
||||
'bmw-m': { sourceSlug: 'bmw-m', downloads: 555 },
|
||||
'bugatti': { sourceSlug: 'bugatti', downloads: 1473 },
|
||||
'cal-com': { sourceSlug: 'cal', downloads: 3691 },
|
||||
'claude': { sourceSlug: 'claude', downloads: 10192 },
|
||||
'clay': { sourceSlug: 'clay', downloads: 3383 },
|
||||
'clickhouse': { sourceSlug: 'clickhouse', downloads: 2495 },
|
||||
'cohere': { sourceSlug: 'cohere', downloads: 3083 },
|
||||
'coinbase': { sourceSlug: 'coinbase', downloads: 3399 },
|
||||
'composio': { sourceSlug: 'composio', downloads: 2346 },
|
||||
'cursor': { sourceSlug: 'cursor', downloads: 4650 },
|
||||
'elevenlabs': { sourceSlug: 'elevenlabs', downloads: 3438 },
|
||||
'expo': { sourceSlug: 'expo', downloads: 2457 },
|
||||
'ferrari': { sourceSlug: 'ferrari', downloads: 3158 },
|
||||
'figma': { sourceSlug: 'figma', downloads: 4351 },
|
||||
'framer': { sourceSlug: 'framer', downloads: 3781 },
|
||||
'hashicorp': { sourceSlug: 'hashicorp', downloads: 2498 },
|
||||
'ibm': { sourceSlug: 'ibm', downloads: 3093 },
|
||||
'intercom': { sourceSlug: 'intercom', downloads: 3033 },
|
||||
'june': { sourceSlug: 'june', downloads: 0 },
|
||||
'kraken': { sourceSlug: 'kraken', downloads: 2738 },
|
||||
'lamborghini': { sourceSlug: 'lamborghini', downloads: 2632 },
|
||||
'linear': { sourceSlug: 'linear.app', downloads: 12010 },
|
||||
'lovable': { sourceSlug: 'lovable', downloads: 2907 },
|
||||
'mastercard': { sourceSlug: 'mastercard', downloads: 1275 },
|
||||
'meta': { sourceSlug: 'meta', downloads: 1927 },
|
||||
'minimax': { sourceSlug: 'minimax', downloads: 2742 },
|
||||
'mintlify': { sourceSlug: 'mintlify', downloads: 3271 },
|
||||
'miro': { sourceSlug: 'miro', downloads: 2587 },
|
||||
'mistral-ai': { sourceSlug: 'mistral.ai', downloads: 2481 },
|
||||
'mongodb': { sourceSlug: 'mongodb', downloads: 2399 },
|
||||
'nike': { sourceSlug: 'nike', downloads: 2158 },
|
||||
'notion': { sourceSlug: 'notion', downloads: 11442 },
|
||||
'nvidia': { sourceSlug: 'nvidia', downloads: 2610 },
|
||||
'ollama': { sourceSlug: 'ollama', downloads: 2602 },
|
||||
'opencode': { sourceSlug: 'opencode.ai', downloads: 3033 },
|
||||
'pinterest': { sourceSlug: 'pinterest', downloads: 2837 },
|
||||
'playstation': { sourceSlug: 'playstation', downloads: 1507 },
|
||||
'posthog': { sourceSlug: 'posthog', downloads: 2949 },
|
||||
'renault': { sourceSlug: 'renault', downloads: 2186 },
|
||||
'replicate': { sourceSlug: 'replicate', downloads: 2267 },
|
||||
'revolut': { sourceSlug: 'revolut', downloads: 3289 },
|
||||
'runway': { sourceSlug: 'runwayml', downloads: 2496 },
|
||||
'sanity': { sourceSlug: 'sanity', downloads: 2322 },
|
||||
'sentry': { sourceSlug: 'sentry', downloads: 2949 },
|
||||
'shopify': { sourceSlug: 'shopify', downloads: 2063 },
|
||||
'slack': { sourceSlug: 'slack', downloads: 0 },
|
||||
'spacex': { sourceSlug: 'spacex', downloads: 3053 },
|
||||
'spotify': { sourceSlug: 'spotify', downloads: 4283 },
|
||||
'starbucks': { sourceSlug: 'starbucks', downloads: 1100 },
|
||||
'stripe': { sourceSlug: 'stripe', downloads: 9401 },
|
||||
'supabase': { sourceSlug: 'supabase', downloads: 4044 },
|
||||
'superhuman': { sourceSlug: 'superhuman', downloads: 3186 },
|
||||
'tesla': { sourceSlug: 'tesla', downloads: 3228 },
|
||||
'the-verge': { sourceSlug: 'theverge', downloads: 1508 },
|
||||
'together-ai': { sourceSlug: 'together.ai', downloads: 2352 },
|
||||
'uber': { sourceSlug: 'uber', downloads: 2933 },
|
||||
'vercel': { sourceSlug: 'vercel', downloads: 10946 },
|
||||
'vodafone': { sourceSlug: 'vodafone', downloads: 750 },
|
||||
'voltagent': { sourceSlug: 'voltagent', downloads: 2847 },
|
||||
'warp': { sourceSlug: 'warp', downloads: 2541 },
|
||||
'webflow': { sourceSlug: 'webflow', downloads: 2596 },
|
||||
'wired': { sourceSlug: 'wired', downloads: 1488 },
|
||||
'wise': { sourceSlug: 'wise', downloads: 3274 },
|
||||
'xai': { sourceSlug: 'x.ai', downloads: 2599 },
|
||||
'zapier': { sourceSlug: 'zapier', downloads: 2597 },
|
||||
};
|
||||
|
||||
function toPosix(input) {
|
||||
return String(input || '').replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
function sortById(left, right) {
|
||||
return left.id.localeCompare(right.id);
|
||||
}
|
||||
|
||||
function getThemeStats(themeId) {
|
||||
return GETDESIGN_THEME_STATS_BY_ID[themeId] || null;
|
||||
}
|
||||
|
||||
function sortThemesByGetDesignDownloads(left, right) {
|
||||
const leftStats = getThemeStats(left.id);
|
||||
const rightStats = getThemeStats(right.id);
|
||||
if (leftStats && rightStats) {
|
||||
return rightStats.downloads - leftStats.downloads || sortById(left, right);
|
||||
}
|
||||
if (leftStats) return -1;
|
||||
if (rightStats) return 1;
|
||||
return sortById(left, right);
|
||||
}
|
||||
|
||||
function readJson(filePath) {
|
||||
if (!fs.existsSync(filePath)) return null;
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function stringValue(value) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function isLegacyOfficialProjectName(projectId, name) {
|
||||
return projectId === PROJECT_ID && (name === 'Axhub Make' || name === 'Axhub-Make');
|
||||
}
|
||||
|
||||
export function normalizeMakeClientProjectIdentity(project) {
|
||||
const rawProject = project && typeof project === 'object' && !Array.isArray(project)
|
||||
? project
|
||||
: {};
|
||||
const id = stringValue(rawProject.id) || PROJECT_ID;
|
||||
const name = typeof rawProject.name === 'string' ? rawProject.name.trim() : '';
|
||||
return {
|
||||
id,
|
||||
name: isLegacyOfficialProjectName(id, name) ? '' : name,
|
||||
};
|
||||
}
|
||||
|
||||
const PAGE_ID_RE = /^[a-z0-9-]+$/u;
|
||||
|
||||
function normalizePageId(value) {
|
||||
const id = stringValue(value);
|
||||
return PAGE_ID_RE.test(id) ? id : '';
|
||||
}
|
||||
|
||||
export function readMakeClientProjectIdentity(projectRoot) {
|
||||
const marker = readJson(path.join(projectRoot, MAKE_CLIENT_MARKER_RELATIVE_PATH));
|
||||
const project = marker?.project && typeof marker.project === 'object' && !Array.isArray(marker.project)
|
||||
? marker.project
|
||||
: {};
|
||||
const id = stringValue(project.id);
|
||||
if (marker?.schemaVersion !== 1 || marker?.kind !== MAKE_CLIENT_MARKER_KIND || !id) {
|
||||
return {
|
||||
id: PROJECT_ID,
|
||||
name: PROJECT_NAME,
|
||||
};
|
||||
}
|
||||
return normalizeMakeClientProjectIdentity(project);
|
||||
}
|
||||
|
||||
function writeJsonAtomic(filePath, value) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
|
||||
try {
|
||||
fs.writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
||||
fs.renameSync(tempPath, filePath);
|
||||
} finally {
|
||||
if (fs.existsSync(tempPath)) {
|
||||
fs.unlinkSync(tempPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function listFiles(rootDir, predicate) {
|
||||
if (!fs.existsSync(rootDir)) return [];
|
||||
const result = [];
|
||||
for (const entry of fs.readdirSync(rootDir, { withFileTypes: true })) {
|
||||
if (entry.name.startsWith('.')) continue;
|
||||
const fullPath = path.join(rootDir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
result.push(...listFiles(fullPath, predicate));
|
||||
continue;
|
||||
}
|
||||
if (entry.isFile() && predicate(fullPath)) {
|
||||
result.push(fullPath);
|
||||
}
|
||||
}
|
||||
return result.sort((a, b) => toPosix(a).localeCompare(toPosix(b)));
|
||||
}
|
||||
|
||||
function isIgnoredResourceRelativePath(relativePath) {
|
||||
const normalized = toPosix(relativePath).replace(/^\/+|\/+$/g, '');
|
||||
if (!normalized) return true;
|
||||
if (normalized.toLowerCase() === 'readme.md') return true;
|
||||
return normalized.split('/').some((segment) => segment.startsWith('.'));
|
||||
}
|
||||
|
||||
function readDisplayName(indexFilePath, fallback) {
|
||||
if (!fs.existsSync(indexFilePath)) return fallback;
|
||||
const source = fs.readFileSync(indexFilePath, 'utf8');
|
||||
const displayName = source.match(/@name\s+([^\n]+)/)?.[1]?.replace(/\*\/\s*$/u, '').trim();
|
||||
return displayName || fallback;
|
||||
}
|
||||
|
||||
function getLiteralPropertyValue(objectLiteral, propertyName) {
|
||||
const property = objectLiteral.properties.find((candidate) => (
|
||||
ts.isPropertyAssignment(candidate)
|
||||
&& (
|
||||
(ts.isIdentifier(candidate.name) && candidate.name.text === propertyName)
|
||||
|| (ts.isStringLiteral(candidate.name) && candidate.name.text === propertyName)
|
||||
)
|
||||
));
|
||||
if (!property || !ts.isPropertyAssignment(property)) {
|
||||
return null;
|
||||
}
|
||||
const initializer = property.initializer;
|
||||
return ts.isStringLiteral(initializer) || ts.isNoSubstitutionTemplateLiteral(initializer)
|
||||
? initializer.text.trim()
|
||||
: null;
|
||||
}
|
||||
|
||||
function extractHashRouteFromCall(callExpression) {
|
||||
const expression = callExpression.expression;
|
||||
if (!ts.isIdentifier(expression) || expression.text !== 'defineHashPageRoute') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pagesArg = callExpression.arguments[0];
|
||||
if (!pagesArg || !ts.isArrayLiteralExpression(pagesArg)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pages = [];
|
||||
for (const element of pagesArg.elements) {
|
||||
if (!ts.isObjectLiteralExpression(element)) {
|
||||
continue;
|
||||
}
|
||||
const id = normalizePageId(getLiteralPropertyValue(element, 'id'));
|
||||
const title = stringValue(getLiteralPropertyValue(element, 'title'));
|
||||
if (id && title) {
|
||||
pages.push({ id, title });
|
||||
}
|
||||
}
|
||||
if (!pages.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const optionsArg = callExpression.arguments[1];
|
||||
const requestedDefaultPageId = optionsArg && ts.isObjectLiteralExpression(optionsArg)
|
||||
? normalizePageId(getLiteralPropertyValue(optionsArg, 'defaultPageId'))
|
||||
: '';
|
||||
const defaultPageId = pages.some((page) => page.id === requestedDefaultPageId)
|
||||
? requestedDefaultPageId
|
||||
: pages[0].id;
|
||||
|
||||
return {
|
||||
pages,
|
||||
defaultPageId,
|
||||
};
|
||||
}
|
||||
|
||||
function extractHashRouteFromFile(filePath) {
|
||||
const sourceText = fs.readFileSync(filePath, 'utf8');
|
||||
const sourceFile = ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.Latest, true, filePath.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS);
|
||||
let route = null;
|
||||
|
||||
const visit = (node) => {
|
||||
if (route) {
|
||||
return;
|
||||
}
|
||||
if (ts.isCallExpression(node)) {
|
||||
route = extractHashRouteFromCall(node);
|
||||
if (route) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
|
||||
visit(sourceFile);
|
||||
return route;
|
||||
}
|
||||
|
||||
function extractHashRouteMetadata(prototypeDir) {
|
||||
const files = listFiles(prototypeDir, (filePath) => /\.(tsx?|mts|cts)$/iu.test(filePath));
|
||||
for (const filePath of files) {
|
||||
try {
|
||||
const route = extractHashRouteFromFile(filePath);
|
||||
if (route) {
|
||||
return route;
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed source and leave the prototype without page metadata.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function titleFromMarkdown(markdownPath, fallback) {
|
||||
if (!fs.existsSync(markdownPath)) return fallback;
|
||||
const source = fs.readFileSync(markdownPath, 'utf8');
|
||||
const heading = source.match(/^#\s+(.+)$/mu)?.[1]?.trim();
|
||||
return heading || fallback;
|
||||
}
|
||||
|
||||
function titleFromTokenFile(tokenPath, fallback) {
|
||||
const token = readJson(tokenPath);
|
||||
if (token && typeof token === 'object') {
|
||||
const title = typeof token.title === 'string'
|
||||
? token.title.trim()
|
||||
: typeof token.name === 'string'
|
||||
? token.name.trim()
|
||||
: '';
|
||||
if (title) return title;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function normalizeThemeResourceTitle(title, fallback) {
|
||||
const rawTitle = typeof title === 'string' ? title.trim() : '';
|
||||
const fallbackTitle = typeof fallback === 'string' ? fallback.trim() : '';
|
||||
if (!rawTitle) return fallbackTitle;
|
||||
|
||||
const repeatedThemeTitle = rawTitle.match(/^(.+?)\s+主题\s*-\s*(.+)$/u);
|
||||
if (repeatedThemeTitle) {
|
||||
const before = repeatedThemeTitle[1]?.trim();
|
||||
const after = repeatedThemeTitle[2]?.trim();
|
||||
if (before && after && before.toLowerCase() === after.toLowerCase()) {
|
||||
return after;
|
||||
}
|
||||
}
|
||||
|
||||
return rawTitle;
|
||||
}
|
||||
|
||||
function createAxureArtifactMetadata(projectRoot, prototypeName) {
|
||||
const artifactRoot = path.join(projectRoot, '.axhub/make/artifacts/axure', prototypeName);
|
||||
const files = {
|
||||
manifestPath: '.axhub/make/artifacts/axure/{name}/manifest.json',
|
||||
indexBundlePath: '.axhub/make/artifacts/axure/{name}/index-bundle.json',
|
||||
axureJsonPath: '.axhub/make/artifacts/axure/{name}/axure-json.json',
|
||||
coverSvgPath: '.axhub/make/artifacts/axure/{name}/cover.svg',
|
||||
};
|
||||
const existing = {};
|
||||
for (const [key, template] of Object.entries(files)) {
|
||||
const relativePath = template.replace('{name}', prototypeName);
|
||||
if (fs.existsSync(path.join(projectRoot, relativePath))) {
|
||||
existing[key] = relativePath;
|
||||
}
|
||||
}
|
||||
if (!Object.keys(existing).length && !fs.existsSync(artifactRoot)) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
axure: {
|
||||
caseId: prototypeName,
|
||||
...existing,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createFigmaArtifactMetadata(projectRoot, prototypeName) {
|
||||
const artifactRoot = path.join(projectRoot, '.axhub/make/artifacts/figma', prototypeName);
|
||||
const files = {
|
||||
canvasFigPath: '.axhub/make/artifacts/figma/{name}/canvas.fig',
|
||||
metaPath: '.axhub/make/artifacts/figma/{name}/meta.json',
|
||||
aiChatPath: '.axhub/make/artifacts/figma/{name}/ai_chat.json',
|
||||
codeManifestPath: '.axhub/make/artifacts/figma/{name}/canvas.code-manifest.json',
|
||||
manifestPath: '.axhub/make/artifacts/figma/{name}/manifest.json',
|
||||
thumbnailPath: '.axhub/make/artifacts/figma/{name}/thumbnail.png',
|
||||
};
|
||||
const existing = {};
|
||||
for (const [key, template] of Object.entries(files)) {
|
||||
const relativePath = template.replace('{name}', prototypeName);
|
||||
if (fs.existsSync(path.join(projectRoot, relativePath))) {
|
||||
existing[key] = relativePath;
|
||||
}
|
||||
}
|
||||
const imagesRelativePath = `.axhub/make/artifacts/figma/${prototypeName}/images`;
|
||||
if (fs.existsSync(path.join(projectRoot, imagesRelativePath))) {
|
||||
existing.imagesDir = imagesRelativePath;
|
||||
}
|
||||
if (!Object.keys(existing).length && !fs.existsSync(artifactRoot)) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
figma: {
|
||||
resourceId: prototypeName,
|
||||
...existing,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function readRuntimeEntryKeys(projectRoot) {
|
||||
const manifest = readJson(path.join(projectRoot, '.axhub/make/entries.json'));
|
||||
const items = manifest && typeof manifest === 'object' && manifest.items && typeof manifest.items === 'object'
|
||||
? manifest.items
|
||||
: {};
|
||||
const legacyJs = manifest && typeof manifest === 'object' && manifest.js && typeof manifest.js === 'object'
|
||||
? manifest.js
|
||||
: {};
|
||||
const keys = new Set([
|
||||
...Object.keys(items),
|
||||
...Object.keys(legacyJs),
|
||||
]);
|
||||
|
||||
return Array.from(keys)
|
||||
.map((key) => {
|
||||
const normalizedKey = toPosix(key).replace(/^\/+/, '').replace(/\/+$/, '');
|
||||
const item = items[key];
|
||||
const group = stringValue(item?.group) || normalizedKey.split('/')[0] || '';
|
||||
const name = stringValue(item?.name) || normalizedKey.split('/').slice(1).join('/') || '';
|
||||
if (!normalizedKey || group !== 'prototypes' || !name) {
|
||||
return null;
|
||||
}
|
||||
return { key: normalizedKey, name };
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function createRuntimeArtifactMetadata(projectRoot, prototypeName, runtimeEntryKeys, options = {}) {
|
||||
if (options.includeRuntimeArtifacts === false) {
|
||||
return undefined;
|
||||
}
|
||||
const entry = runtimeEntryKeys.find((item) => item.name === prototypeName || item.key === `prototypes/${prototypeName}`);
|
||||
if (!entry) {
|
||||
return undefined;
|
||||
}
|
||||
const builtJsPath = `dist/${entry.key}.js`;
|
||||
if (!fs.existsSync(path.join(projectRoot, builtJsPath))) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
runtime: {
|
||||
builtJsPath,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createResourceClientUrl(clientOrigin, resourceKind, resourceName) {
|
||||
const pathname = `/${resourceKind}/${encodeURIComponent(resourceName)}`;
|
||||
const normalizedOrigin = String(clientOrigin || '').trim().replace(/\/+$/u, '');
|
||||
if (!normalizedOrigin) {
|
||||
return pathname;
|
||||
}
|
||||
return `${normalizedOrigin}${pathname}`;
|
||||
}
|
||||
|
||||
function collectPrototypes(projectRoot, clientOrigin, options = {}) {
|
||||
const roots = resourceLayout.prototypes.map((dir) => path.join(projectRoot, dir));
|
||||
const runtimeEntryKeys = readRuntimeEntryKeys(projectRoot);
|
||||
const items = [];
|
||||
for (const root of roots) {
|
||||
if (!fs.existsSync(root)) continue;
|
||||
for (const entry of fs.readdirSync(root, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const indexFile = path.join(root, entry.name, 'index.tsx');
|
||||
if (!fs.existsSync(indexFile)) continue;
|
||||
const filePath = toPosix(path.relative(projectRoot, indexFile));
|
||||
const route = extractHashRouteMetadata(path.join(root, entry.name));
|
||||
const item = {
|
||||
id: entry.name,
|
||||
name: entry.name,
|
||||
title: readDisplayName(indexFile, entry.name),
|
||||
clientUrl: createResourceClientUrl(clientOrigin, 'prototypes', entry.name),
|
||||
previewMode: 'clientRuntime',
|
||||
description: '',
|
||||
updatedAt: DETERMINISTIC_UPDATED_AT,
|
||||
filePath,
|
||||
...(options.includeAbsoluteFilePaths === false ? {} : { absoluteFilePath: path.resolve(indexFile) }),
|
||||
...(route ? { pages: route.pages, defaultPageId: route.defaultPageId } : {}),
|
||||
};
|
||||
const artifacts = {
|
||||
...createFigmaArtifactMetadata(projectRoot, entry.name),
|
||||
...createAxureArtifactMetadata(projectRoot, entry.name),
|
||||
...createRuntimeArtifactMetadata(projectRoot, entry.name, runtimeEntryKeys, options),
|
||||
};
|
||||
if (Object.keys(artifacts).length > 0) {
|
||||
item.artifacts = artifacts;
|
||||
}
|
||||
items.push(item);
|
||||
}
|
||||
}
|
||||
return items.sort(sortById);
|
||||
}
|
||||
|
||||
function collectDocs(projectRoot, options = {}) {
|
||||
const docs = [];
|
||||
for (const root of resourceLayout.docs.map((dir) => path.resolve(projectRoot, dir))) {
|
||||
for (const filePath of listFiles(root, () => true)) {
|
||||
const relativePath = toPosix(path.relative(root, filePath));
|
||||
if (isIgnoredResourceRelativePath(relativePath)) continue;
|
||||
const isMarkdown = path.extname(filePath).toLowerCase() === '.md';
|
||||
const id = isMarkdown ? relativePath.replace(/\.md$/iu, '') : relativePath;
|
||||
docs.push({
|
||||
id,
|
||||
name: id,
|
||||
title: isMarkdown
|
||||
? titleFromMarkdown(filePath, path.basename(filePath, '.md'))
|
||||
: relativePath.replace(/\.[^.]+$/u, ''),
|
||||
path: options.includeAbsoluteFilePaths === false
|
||||
? toPosix(path.relative(projectRoot, filePath))
|
||||
: path.resolve(filePath),
|
||||
description: '',
|
||||
updatedAt: DETERMINISTIC_UPDATED_AT,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return docs.sort(sortById);
|
||||
}
|
||||
|
||||
function collectThemes(projectRoot, clientOrigin) {
|
||||
const items = [];
|
||||
for (const root of resourceLayout.themes.map((dir) => path.resolve(projectRoot, dir))) {
|
||||
if (!fs.existsSync(root)) continue;
|
||||
for (const entry of fs.readdirSync(root, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const indexFile = path.join(root, entry.name, 'index.tsx');
|
||||
if (!fs.existsSync(indexFile)) continue;
|
||||
const tokenPath = path.join(root, entry.name, 'designToken.json');
|
||||
const getdesignStats = getThemeStats(entry.name);
|
||||
const rawTitle = readDisplayName(indexFile, titleFromTokenFile(tokenPath, entry.name));
|
||||
items.push({
|
||||
id: entry.name,
|
||||
name: entry.name,
|
||||
title: normalizeThemeResourceTitle(rawTitle, entry.name),
|
||||
clientUrl: createResourceClientUrl(clientOrigin, 'themes', entry.name),
|
||||
sourcePath: toPosix(path.relative(projectRoot, path.join(root, entry.name))),
|
||||
updatedAt: DETERMINISTIC_UPDATED_AT,
|
||||
...(getdesignStats
|
||||
? {
|
||||
getdesign: {
|
||||
source: 'getdesign.md',
|
||||
sourceSlug: getdesignStats.sourceSlug,
|
||||
downloads: getdesignStats.downloads,
|
||||
snapshotDate: GETDESIGN_DOWNLOAD_SNAPSHOT_DATE,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
return items
|
||||
.sort(sortThemesByGetDesignDownloads)
|
||||
.map((item) => Object.fromEntries(Object.entries(item).filter(([, value]) => value !== undefined)));
|
||||
}
|
||||
|
||||
export function buildMakeProjectMetadata(projectRoot, options = {}) {
|
||||
const clientOrigin = String(options.clientOrigin ?? DEFAULT_CLIENT_ORIGIN).replace(/\/+$/u, '');
|
||||
const projectIdentity = readMakeClientProjectIdentity(projectRoot);
|
||||
const prototypes = collectPrototypes(projectRoot, clientOrigin, options);
|
||||
const docs = collectDocs(projectRoot, options);
|
||||
const themes = collectThemes(projectRoot, clientOrigin);
|
||||
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
project: {
|
||||
id: projectIdentity.id,
|
||||
name: projectIdentity.name,
|
||||
},
|
||||
resources: {
|
||||
prototypes,
|
||||
docs,
|
||||
themes,
|
||||
},
|
||||
navigation: {
|
||||
prototypes: prototypes.map((item) => item.id),
|
||||
docs: docs.map((item) => item.id),
|
||||
},
|
||||
orders: {
|
||||
themes: themes.map((item) => item.id),
|
||||
},
|
||||
capabilities: {
|
||||
quickEdit: true,
|
||||
quickEditMode: 'clientRuntime',
|
||||
figmaExport: true,
|
||||
axureExport: true,
|
||||
localExports: localExportCapabilities,
|
||||
},
|
||||
resourceWriteTargets,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveClientOrigin(projectRoot, fallbackOrigin = DEFAULT_CLIENT_ORIGIN) {
|
||||
const runtime = readServerInfo(projectRoot, 'runtime');
|
||||
return runtime?.origin || fallbackOrigin;
|
||||
}
|
||||
|
||||
export function syncMakeProjectMetadata(projectRoot, options = {}) {
|
||||
const metadata = buildMakeProjectMetadata(projectRoot, {
|
||||
clientOrigin: options.includeRuntimeUrls === true
|
||||
? options.clientOrigin ?? resolveClientOrigin(projectRoot)
|
||||
: '',
|
||||
includeAbsoluteFilePaths: options.includeAbsoluteFilePaths === true,
|
||||
includeRuntimeArtifacts: options.includeRuntimeArtifacts === true,
|
||||
});
|
||||
const metadataPath = path.join(projectRoot, '.axhub/make/project.json');
|
||||
writeJsonAtomic(metadataPath, metadata);
|
||||
return { metadata, metadataPath };
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === __filename) {
|
||||
const appRoot = path.resolve(__dirname, '..');
|
||||
const { metadata, metadataPath } = syncMakeProjectMetadata(appRoot);
|
||||
console.log(`Synced ${metadata.project.name || 'unnamed project'} metadata: ${metadataPath}`);
|
||||
}
|
||||
32
scripts/sync-project-metadata.mjs.d.ts
vendored
Normal file
32
scripts/sync-project-metadata.mjs.d.ts
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
export const PROJECT_ID: string;
|
||||
export const PROJECT_NAME: string;
|
||||
export const PRODUCT_NAME: string;
|
||||
export const DEFAULT_CLIENT_ORIGIN: string;
|
||||
export const DETERMINISTIC_UPDATED_AT: string;
|
||||
export const resourceLayout: Record<string, string[]>;
|
||||
|
||||
export function normalizeMakeClientProjectIdentity(project: unknown): {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export function readMakeClientProjectIdentity(projectRoot: string): {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export function buildMakeProjectMetadata(projectRoot: string, options?: {
|
||||
clientOrigin?: string;
|
||||
includeAbsoluteFilePaths?: boolean;
|
||||
includeRuntimeArtifacts?: boolean;
|
||||
}): any;
|
||||
export function resolveClientOrigin(projectRoot: string, fallbackOrigin?: string): string;
|
||||
export function syncMakeProjectMetadata(projectRoot: string, options?: {
|
||||
clientOrigin?: string;
|
||||
includeRuntimeUrls?: boolean;
|
||||
includeAbsoluteFilePaths?: boolean;
|
||||
includeRuntimeArtifacts?: boolean;
|
||||
}): {
|
||||
metadata: any;
|
||||
metadataPath: string;
|
||||
};
|
||||
53
scripts/sync-vendor-if-present.mjs
Normal file
53
scripts/sync-vendor-if-present.mjs
Normal file
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { runCommandSync } from './utils/command-runtime.mjs';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const clientRoot = path.resolve(__dirname, '..');
|
||||
|
||||
function findWorkspaceRoot(startDir) {
|
||||
let current = path.resolve(startDir);
|
||||
while (true) {
|
||||
if (fs.existsSync(path.join(current, 'pnpm-workspace.yaml'))) {
|
||||
return current;
|
||||
}
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) {
|
||||
return null;
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function hasMakePackage(workspaceRoot) {
|
||||
try {
|
||||
const packageJson = JSON.parse(fs.readFileSync(path.join(workspaceRoot, 'apps', 'axhub-make', 'package.json'), 'utf8'));
|
||||
return packageJson?.name === '@axhub/make';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const workspaceRoot = findWorkspaceRoot(clientRoot);
|
||||
if (!workspaceRoot || !hasMakePackage(workspaceRoot)) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const result = runCommandSync({
|
||||
command: 'pnpm',
|
||||
args: ['--filter', '@axhub/make', 'vendor:sync'],
|
||||
cwd: workspaceRoot,
|
||||
timeoutMs: 10 * 60 * 1000,
|
||||
maxBuffer: 20 * 1024 * 1024,
|
||||
});
|
||||
|
||||
if (result.status !== 0) {
|
||||
const stderr = result.stderr.trim();
|
||||
const stdout = result.stdout.trim();
|
||||
process.stderr.write(`${stderr || stdout || 'Failed to sync Make vendor packages'}\n`);
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
593
scripts/templates/empty-canvas.code-manifest.json
Normal file
593
scripts/templates/empty-canvas.code-manifest.json
Normal file
@@ -0,0 +1,593 @@
|
||||
{
|
||||
"command": "inspect",
|
||||
"generatedAt": "2026-04-01T10:21:53.845Z",
|
||||
"figPath": "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
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
scripts/templates/empty-canvas.fig
Normal file
BIN
scripts/templates/empty-canvas.fig
Normal file
Binary file not shown.
63
scripts/utils/command-runtime.d.ts
vendored
Normal file
63
scripts/utils/command-runtime.d.ts
vendored
Normal 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;
|
||||
340
scripts/utils/command-runtime.mjs
Normal file
340
scripts/utils/command-runtime.mjs
Normal 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;
|
||||
}
|
||||
41
scripts/utils/generatedTsxValidator.mjs
Normal file
41
scripts/utils/generatedTsxValidator.mjs
Normal 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}`);
|
||||
}
|
||||
}
|
||||
26
scripts/utils/serverInfo.d.ts
vendored
Normal file
26
scripts/utils/serverInfo.d.ts
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
export type AxhubServerRole = 'runtime' | 'admin';
|
||||
|
||||
export interface AxhubServerInfo {
|
||||
pid: number;
|
||||
port: number;
|
||||
host: string;
|
||||
origin: string;
|
||||
projectRoot: string;
|
||||
startedAt: string;
|
||||
}
|
||||
|
||||
export interface ServerInfoPathOptions {
|
||||
homeDir?: string;
|
||||
}
|
||||
|
||||
export function readServerInfo(projectRoot: string, role: AxhubServerRole, options?: ServerInfoPathOptions): AxhubServerInfo | null;
|
||||
export function getAdminServerInfoPath(projectRoot?: string, options?: ServerInfoPathOptions): string;
|
||||
export function getRuntimeServerInfoPath(projectRoot: string): string;
|
||||
export function writeServerInfo(
|
||||
projectRoot: string,
|
||||
role: AxhubServerRole,
|
||||
info: AxhubServerInfo,
|
||||
options?: ServerInfoPathOptions,
|
||||
): AxhubServerInfo;
|
||||
export function fetchHealth(origin: string, timeoutMs?: number): Promise<unknown | null>;
|
||||
export function normalizeHealthServerInfo(data: unknown): AxhubServerInfo | null;
|
||||
108
scripts/utils/serverInfo.mjs
Normal file
108
scripts/utils/serverInfo.mjs
Normal file
@@ -0,0 +1,108 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
const MAKE_STATE_DIR = path.join('.axhub', 'make');
|
||||
const RUNTIME_SERVER_INFO_RELATIVE_PATH = path.join(MAKE_STATE_DIR, '.dev-server-info.json');
|
||||
const ADMIN_SERVER_INFO_RELATIVE_PATH = path.join(MAKE_STATE_DIR, '.admin-server-info.json');
|
||||
const MAKE_HOME_DIR_ENV = 'AXHUB_MAKE_HOME_DIR';
|
||||
|
||||
function resolveProjectRoot(projectRoot) {
|
||||
return path.resolve(projectRoot);
|
||||
}
|
||||
|
||||
function getServerInfoPath(projectRoot, role) {
|
||||
if (role === 'admin') {
|
||||
return getAdminServerInfoPath();
|
||||
}
|
||||
return path.join(resolveProjectRoot(projectRoot), RUNTIME_SERVER_INFO_RELATIVE_PATH);
|
||||
}
|
||||
|
||||
function getGlobalHomeDir(options = {}) {
|
||||
return options.homeDir || process.env[MAKE_HOME_DIR_ENV] || os.homedir();
|
||||
}
|
||||
|
||||
export function getAdminServerInfoPath(_projectRoot, options = {}) {
|
||||
return path.join(path.resolve(getGlobalHomeDir(options)), ADMIN_SERVER_INFO_RELATIVE_PATH);
|
||||
}
|
||||
|
||||
export function getRuntimeServerInfoPath(projectRoot) {
|
||||
return getServerInfoPath(projectRoot, 'runtime');
|
||||
}
|
||||
|
||||
function normalizeServerInfo(data) {
|
||||
if (!data || typeof data !== 'object') {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
typeof data.pid !== 'number'
|
||||
|| typeof data.port !== 'number'
|
||||
|| typeof data.host !== 'string'
|
||||
|| typeof data.origin !== 'string'
|
||||
|| typeof data.projectRoot !== 'string'
|
||||
|| typeof data.startedAt !== 'string'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
pid: data.pid,
|
||||
port: data.port,
|
||||
host: data.host,
|
||||
origin: data.origin,
|
||||
projectRoot: resolveProjectRoot(data.projectRoot),
|
||||
startedAt: data.startedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function readServerInfo(projectRoot, role, options = {}) {
|
||||
const infoPath = role === 'admin'
|
||||
? getAdminServerInfoPath(projectRoot, options)
|
||||
: getServerInfoPath(projectRoot, role);
|
||||
if (!fs.existsSync(infoPath)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return normalizeServerInfo(JSON.parse(fs.readFileSync(infoPath, 'utf8')));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeServerInfo(projectRoot, role, info, options = {}) {
|
||||
const normalized = {
|
||||
...info,
|
||||
projectRoot: resolveProjectRoot(info.projectRoot),
|
||||
};
|
||||
const infoPath = role === 'admin'
|
||||
? getAdminServerInfoPath(projectRoot, options)
|
||||
: getServerInfoPath(projectRoot, role);
|
||||
fs.mkdirSync(path.dirname(infoPath), { recursive: true });
|
||||
fs.writeFileSync(infoPath, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8');
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export async function fetchHealth(origin, timeoutMs = 1000) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await fetch(new URL('/api/health', origin), {
|
||||
signal: controller.signal,
|
||||
headers: { accept: 'application/json' },
|
||||
});
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
return await response.json();
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeHealthServerInfo(data) {
|
||||
if (!data || typeof data !== 'object') {
|
||||
return null;
|
||||
}
|
||||
return normalizeServerInfo(data.server ?? data);
|
||||
}
|
||||
115
scripts/v0-converter.mjs
Normal file
115
scripts/v0-converter.mjs
Normal file
@@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
function normalizeSlashes(input) {
|
||||
return String(input || '').replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
function sanitizeName(rawName) {
|
||||
return String(rawName || '')
|
||||
.replace(/[^a-z0-9-]/gi, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = [...argv];
|
||||
const projectDirArg = args.shift();
|
||||
const outputNameArg = args.shift();
|
||||
let targetType = 'prototypes';
|
||||
let projectRoot = process.cwd();
|
||||
let outputBaseDir = '';
|
||||
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
if (arg === '--target-type') {
|
||||
targetType = String(args[index + 1] || '').trim();
|
||||
index += 1;
|
||||
} else if (arg === '--project-root') {
|
||||
projectRoot = path.resolve(args[index + 1] || projectRoot);
|
||||
index += 1;
|
||||
} else if (arg === '--output-base-dir') {
|
||||
outputBaseDir = path.resolve(args[index + 1] || '');
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (!projectDirArg) throw new Error('Missing project directory');
|
||||
const outputName = sanitizeName(outputNameArg || path.basename(projectDirArg));
|
||||
if (!outputName) throw new Error('Missing valid output name');
|
||||
return {
|
||||
projectDir: path.resolve(projectRoot, projectDirArg),
|
||||
outputName,
|
||||
targetType,
|
||||
projectRoot,
|
||||
outputBaseDir: outputBaseDir || path.resolve(projectRoot, 'src', targetType),
|
||||
};
|
||||
}
|
||||
|
||||
function copyDirectory(src, dest) {
|
||||
if (!fs.existsSync(src)) return 0;
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
let count = 0;
|
||||
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
||||
if (entry.name === 'node_modules' || entry.name === '.next') continue;
|
||||
const srcPath = path.join(src, entry.name);
|
||||
const destPath = path.join(dest, entry.name);
|
||||
if (entry.isDirectory()) count += copyDirectory(srcPath, destPath);
|
||||
else if (entry.isFile()) {
|
||||
fs.copyFileSync(srcPath, destPath);
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function ensureIndex(outputDir) {
|
||||
const indexPath = path.join(outputDir, 'index.tsx');
|
||||
if (!fs.existsSync(indexPath)) {
|
||||
fs.writeFileSync(indexPath, [
|
||||
"import React from 'react';",
|
||||
'',
|
||||
'export default function ImportedV0Prototype() {',
|
||||
' return <div data-import-source="v0">V0 import requires AI conversion.</div>;',
|
||||
'}',
|
||||
'',
|
||||
].join('\n'), 'utf8');
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const parsed = parseArgs(process.argv.slice(2));
|
||||
if (!fs.existsSync(path.join(parsed.projectDir, 'app'))) {
|
||||
throw new Error('这不是一个有效的 V0 项目(缺少 app/ 目录)');
|
||||
}
|
||||
const outputDir = path.join(parsed.outputBaseDir, parsed.outputName);
|
||||
fs.rmSync(outputDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(parsed.outputBaseDir, { recursive: true });
|
||||
const fileCount = copyDirectory(parsed.projectDir, outputDir);
|
||||
ensureIndex(outputDir);
|
||||
const taskPath = path.join(outputDir, parsed.targetType === 'themes' ? '.v0-theme-tasks.md' : '.v0-tasks.md');
|
||||
fs.writeFileSync(taskPath, [
|
||||
'# V0 项目转换任务清单',
|
||||
'',
|
||||
'> 请先阅读 `rules/v0-project-converter.md` 和 `rules/development-guide.md`,再基于该目录完成原型转换。',
|
||||
'',
|
||||
`- 输出目录:\`${normalizeSlashes(path.relative(parsed.projectRoot, outputDir))}/\``,
|
||||
`- 已复制文件数:${fileCount}`,
|
||||
'',
|
||||
].join('\n'), 'utf8');
|
||||
console.log(JSON.stringify({
|
||||
success: true,
|
||||
outputDir,
|
||||
tasksFile: normalizeSlashes(path.relative(parsed.projectRoot, taskPath)),
|
||||
}));
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
console.error(error?.message || String(error));
|
||||
process.exit(1);
|
||||
}
|
||||
Reference in New Issue
Block a user