Initial commit: OneOS prototype workspace baseline.
Include weekly report and other prototype sources with project tooling config. 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,
|
||||
};
|
||||
259
scripts/convert-v266-contract.py
Normal file
259
scripts/convert-v266-contract.py
Normal file
@@ -0,0 +1,259 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Convert textutil Word HTML export to ct-word-doc seed for ContractTemplate."""
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Set
|
||||
|
||||
from bs4 import BeautifulSoup, NavigableString, Tag
|
||||
|
||||
SRC = Path("/tmp/v266-contract.html")
|
||||
OUT = Path(__file__).resolve().parent.parent / "src/prototypes/contract-template-management/v266-lease-document.js"
|
||||
|
||||
# Semantic ct-word classes for key blocks (layout still driven by embedded CSS)
|
||||
SEMANTIC = {
|
||||
"p2": "ct-word-title",
|
||||
"p3": "ct-word-contract-no",
|
||||
"p5": "ct-word-party-line",
|
||||
"p6": "ct-word-party-line",
|
||||
"p7": "ct-word-party-line",
|
||||
"p8": "ct-word-party-line",
|
||||
}
|
||||
|
||||
|
||||
def parse_css_rules(css_text: str) -> Dict[str, str]:
|
||||
rules: Dict[str, str] = {}
|
||||
for block in re.findall(r"([^{]+)\{([^}]+)\}", css_text):
|
||||
selector = block[0].strip()
|
||||
props = block[1].strip()
|
||||
if "," in selector:
|
||||
continue
|
||||
selector = selector.lstrip(".")
|
||||
if re.match(r"^(p|span|td|table)\.", selector):
|
||||
rules[selector] = props
|
||||
return rules
|
||||
|
||||
|
||||
def is_red_style(style: str) -> bool:
|
||||
return bool(re.search(r"color\s*:\s*#ff0000", style, re.I))
|
||||
|
||||
|
||||
def build_red_classes(rules: Dict[str, str]) -> Set[str]:
|
||||
red = set()
|
||||
for cls, props in rules.items():
|
||||
if is_red_style(props):
|
||||
red.add(cls.split(".")[-1])
|
||||
return red
|
||||
|
||||
|
||||
def class_list(tag: Tag) -> List[str]:
|
||||
raw = tag.get("class") or []
|
||||
if isinstance(raw, str):
|
||||
return raw.split()
|
||||
return list(raw)
|
||||
|
||||
|
||||
def primary_class(tag: Tag) -> Optional[str]:
|
||||
classes = class_list(tag)
|
||||
for c in classes:
|
||||
if c in SEMANTIC or re.match(r"^[pst]\d+$", c) or re.match(r"^td\d+$", c):
|
||||
return c
|
||||
return classes[0] if classes else None
|
||||
|
||||
|
||||
def merge_style(tag: Tag, rules: Dict[str, str]) -> None:
|
||||
parts: List[str] = []
|
||||
for c in class_list(tag):
|
||||
key_p = f"p.{c}" if c.startswith("p") else None
|
||||
key_s = f"span.{c}" if c.startswith("s") else None
|
||||
key_td = f"td.{c}" if c.startswith("td") else None
|
||||
key_t = f"table.{c}" if c.startswith("t") else None
|
||||
for key in (key_p, key_s, key_td, key_t):
|
||||
if key and key in rules:
|
||||
parts.append(rules[key])
|
||||
if parts:
|
||||
existing = tag.get("style", "")
|
||||
merged = ";".join([existing] + parts) if existing else ";".join(parts)
|
||||
tag["style"] = merged
|
||||
|
||||
|
||||
def strip_apple_noise(soup: BeautifulSoup) -> None:
|
||||
for el in soup.find_all(class_="Apple-converted-space"):
|
||||
el.replace_with("\u00a0" * max(1, len(el.get_text())))
|
||||
for el in soup.find_all(class_="Apple-tab-span"):
|
||||
el.replace_with("\t")
|
||||
|
||||
|
||||
def wrap_risk_redlines(soup: BeautifulSoup, red_classes: Set[str]) -> None:
|
||||
risk_seq = 0
|
||||
|
||||
def next_id() -> str:
|
||||
nonlocal risk_seq
|
||||
risk_seq += 1
|
||||
return f"risk-{risk_seq}"
|
||||
|
||||
def is_red_tag(tag: Tag) -> bool:
|
||||
pc = primary_class(tag)
|
||||
return pc in red_classes if pc else False
|
||||
|
||||
# Block-level red paragraphs: wrap inner content once
|
||||
for p in list(soup.find_all("p")):
|
||||
if not is_red_tag(p):
|
||||
continue
|
||||
if p.find_parent(class_=lambda x: x and "ct-risk-redline" in x):
|
||||
continue
|
||||
inner = list(p.contents)
|
||||
if not inner:
|
||||
continue
|
||||
wrapper = soup.new_tag(
|
||||
"span",
|
||||
attrs={
|
||||
"class": "ct-risk-redline",
|
||||
"data-risk-redline": "1",
|
||||
"data-risk-id": next_id(),
|
||||
},
|
||||
)
|
||||
for child in inner:
|
||||
wrapper.append(child.extract() if isinstance(child, Tag) else child)
|
||||
p.clear()
|
||||
p.append(wrapper)
|
||||
|
||||
# Inline red spans inside non-red paragraphs
|
||||
for span in list(soup.find_all("span")):
|
||||
if not is_red_tag(span):
|
||||
continue
|
||||
if span.find_parent(class_=lambda x: x and "ct-risk-redline" in x):
|
||||
continue
|
||||
parent_p = span.find_parent("p")
|
||||
if parent_p and is_red_tag(parent_p):
|
||||
continue
|
||||
wrapper = soup.new_tag(
|
||||
"span",
|
||||
attrs={
|
||||
"class": "ct-risk-redline",
|
||||
"data-risk-redline": "1",
|
||||
"data-risk-id": next_id(),
|
||||
},
|
||||
)
|
||||
span.wrap(wrapper)
|
||||
|
||||
|
||||
def apply_semantic_classes(root: Tag) -> None:
|
||||
for tag in root.find_all(True):
|
||||
pc = primary_class(tag)
|
||||
if pc and pc in SEMANTIC:
|
||||
classes = class_list(tag)
|
||||
extra = SEMANTIC[pc]
|
||||
if extra not in classes:
|
||||
tag["class"] = classes + [extra]
|
||||
|
||||
|
||||
def map_tables(root: Tag) -> None:
|
||||
for i, table in enumerate(root.find_all("table")):
|
||||
classes = class_list(table)
|
||||
if "ct-word-table" not in classes:
|
||||
table["class"] = classes + ["ct-doc-table", "ct-word-table"]
|
||||
if i == 0:
|
||||
for td in table.find_all("td"):
|
||||
tdc = primary_class(td)
|
||||
tr = td.find_parent("tr")
|
||||
row_idx = len(list(tr.find_previous_siblings("tr"))) if tr else 0
|
||||
if row_idx == 0:
|
||||
if tdc == "td1":
|
||||
td["class"] = class_list(td) + ["ct-word-party-left"]
|
||||
elif tdc == "td2":
|
||||
td["class"] = class_list(td) + ["ct-word-party-right"]
|
||||
else:
|
||||
td["class"] = class_list(td) + ["ct-word-td"]
|
||||
|
||||
|
||||
def apply_template_vars(html: str) -> str:
|
||||
regex_replacements = [
|
||||
(r"合同编号<span[^>]*>:</span>【LNZLHT\s*<span[^>]*>[\s\S]*?</span>\s*】",
|
||||
"合同编号:【LNZLHT {{contractCode}} 】"),
|
||||
(r"合同编号:【LNZLHT[^】]*】", "合同编号:【LNZLHT {{contractCode}} 】"),
|
||||
]
|
||||
literal_replacements = [
|
||||
("甲方(出租方):羚牛氢能科技(广东)有限公司", "甲方(出租方):{{lessorName}}"),
|
||||
("甲方(出租方): 羚牛氢能科技(广东)有限公司", "甲方(出租方): {{lessorName}}"),
|
||||
("甲方(出租方): 羚牛氢能科技(广东)有限公司", "甲方(出租方): {{lessorName}}"),
|
||||
("甲方:羚牛氢能科技(广东)有限公司", "甲方:{{lessorName}}"),
|
||||
("致:羚牛氢能科技(广东)有限公司", "致:{{lessorName}}"),
|
||||
("户 名:【羚牛氢能科技(广东)有限公司", "户 名:【{{lessorAccountName}}"),
|
||||
("开户行:【招商银行广州萝岗支行 】", "开户行:【{{lessorBankName}} 】"),
|
||||
("账 号:【120924165110201 】", "账 号:【{{lessorBankAccount}} 】"),
|
||||
("乙方</b><span class=\"s3\"><b>(承租方):</b></span>",
|
||||
"乙方</b><span class=\"s3\"><b>(承租方):{{customerName}}</b></span>"),
|
||||
("乙方(承租方):</b>", "乙方(承租方): {{customerName}}</b>"),
|
||||
("乙方(承租方):</b>", "乙方(承租方):{{customerName}}</b>"),
|
||||
]
|
||||
out = html
|
||||
for pattern, repl in regex_replacements:
|
||||
out = re.sub(pattern, repl, out)
|
||||
for old, new in literal_replacements:
|
||||
out = out.replace(old, new)
|
||||
return out
|
||||
|
||||
|
||||
def scope_css(css_text: str) -> str:
|
||||
scoped = []
|
||||
for block in re.findall(r"([^{]+)\{([^}]+)\}", css_text):
|
||||
selector = block[0].strip()
|
||||
props = block[1].strip()
|
||||
if selector.startswith("@") or "," in selector:
|
||||
continue
|
||||
scoped.append(f".ct-word-doc--v266 {selector}{{{props}}}")
|
||||
return "\n".join(scoped)
|
||||
|
||||
|
||||
def convert() -> str:
|
||||
raw = SRC.read_text(encoding="utf-8")
|
||||
css_match = re.search(r"<style[^>]*>([\s\S]*?)</style>", raw)
|
||||
body_match = re.search(r"<body>([\s\S]*?)</body>", raw)
|
||||
if not css_match or not body_match:
|
||||
raise SystemExit("Invalid source HTML")
|
||||
|
||||
rules = parse_css_rules(css_match.group(1))
|
||||
red_classes = build_red_classes(rules)
|
||||
scoped = scope_css(css_match.group(1))
|
||||
|
||||
soup = BeautifulSoup(body_match.group(1), "lxml")
|
||||
# lxml adds html/body wrapper
|
||||
root = soup.body or soup
|
||||
strip_apple_noise(soup)
|
||||
|
||||
for tag in soup.find_all(True):
|
||||
merge_style(tag, rules)
|
||||
|
||||
wrap_risk_redlines(soup, red_classes)
|
||||
apply_semantic_classes(soup)
|
||||
map_tables(soup)
|
||||
|
||||
body_html = "".join(str(c) for c in (soup.body or soup).contents)
|
||||
body_html = apply_template_vars(body_html)
|
||||
body_html = re.sub(r"<p class=\"p1\"><br\s*/?></p>\s*", "", body_html, count=1)
|
||||
|
||||
doc = (
|
||||
'<div class="ct-word-doc ct-word-doc--v266">'
|
||||
f"<style>{scoped}</style>"
|
||||
f"{body_html}"
|
||||
"</div>"
|
||||
)
|
||||
return doc
|
||||
|
||||
|
||||
def main() -> None:
|
||||
html = convert()
|
||||
escaped = json.dumps(html, ensure_ascii=False)
|
||||
OUT.write_text(
|
||||
"// AUTO-GENERATED V26.6 Word HTML — do not edit by hand\n"
|
||||
f"export var V266_LEASE_DOCUMENT_HTML = {escaped};\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
print(f"Wrote {OUT} ({OUT.stat().st_size} bytes)")
|
||||
print(f"Redline markers: {html.count('data-risk-redline')}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
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);
|
||||
}
|
||||
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');
|
||||
});
|
||||
});
|
||||
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