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:
王冕
2026-06-26 15:36:52 +08:00
commit 7ef8852919
1310 changed files with 282074 additions and 0 deletions

View 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
View File

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

View File

@@ -0,0 +1,2 @@
#!/usr/bin/env node
import '../../vendor/axhub-export-core/scripts/canvas-fig-sync.mjs';

File diff suppressed because it is too large Load Diff

View 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
View 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()

View 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(/&amp;/g, '&');
// 跳过 Tailwind CDN
if (src.includes('tailwindcss.com')) continue;
scripts.push({
src,
id: attrs.match(/id=["']([^"']+)["']/)?.[1]
});
} else if (content) {
const id = attrs.match(/id=["']([^"']+)["']/)?.[1];
scripts.push({ id, content });
}
}
// 提取 link 标签
const linkRegex = /<link[^>]*>/gi;
while ((match = linkRegex.exec(headContent)) !== null) {
const tag = match[0];
const href = tag.match(/href=["']([^"']+)["']/)?.[1];
if (href) {
links.push({
href: href.replace(/&amp;/g, '&'),
rel: tag.match(/rel=["']([^"']+)["']/)?.[1] || 'stylesheet',
crossorigin: tag.includes('crossorigin')
});
}
}
return { scripts, links };
}
/**
* 转义文本节点中的花括号
* 只处理标签之间的文本内容,不处理属性值
*/
function escapeTextBraces(html) {
const parts = [];
let lastIndex = 0;
const tagRegex = /<[^>]+>/g;
let match;
while ((match = tagRegex.exec(html)) !== null) {
// 提取标签之前的文本
const textBefore = html.substring(lastIndex, match.index);
if (textBefore) {
// 转义文本中的花括号 - 使用占位符避免重复替换
const escaped = textBefore
.replace(/\{/g, "__LBRACE__")
.replace(/\}/g, "__RBRACE__");
parts.push(escaped);
}
// 添加标签本身(不转义)
parts.push(match[0]);
lastIndex = tagRegex.lastIndex;
}
// 添加最后一段文本
const textAfter = html.substring(lastIndex);
if (textAfter) {
const escaped = textAfter
.replace(/\{/g, "__LBRACE__")
.replace(/\}/g, "__RBRACE__");
parts.push(escaped);
}
return parts.join('')
.replace(/__LBRACE__/g, "{'{'}")
.replace(/__RBRACE__/g, "{'}'}")
}
function convertCommonAttributesToJSX(content) {
let nextContent = content;
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(/&lt;\//g, '__LTSLASH__')
.replace(/&lt;/g, '__LT__')
.replace(/&gt;/g, '__GT__')
.replace(/&amp;/g, '__AMP__');
const { withPlaceholders, comments } = createCommentPlaceholders(nextContent);
nextContent = escapeTextBraces(withPlaceholders);
nextContent = restoreCommentPlaceholders(nextContent, comments);
return nextContent
.replace(/__LTSLASH__/g, "{'</'}")
.replace(/__LT__/g, "{'<'}")
.replace(/__GT__/g, "{'>'}")
.replace(/__AMP__/g, '&');
}
/**
* 提取并转换 body 内容
*/
function extractBodyContent(html) {
const 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(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&');
const styles = [];
let currentProp = '';
let inUrl = false;
for (let i = 0; i < decodedStr.length; i++) {
const char = decodedStr[i];
if (char === '(' && decodedStr.substring(i - 3, i) === 'url') inUrl = true;
else if (char === ')' && inUrl) inUrl = false;
if (char === ';' && !inUrl) {
if (currentProp.trim()) styles.push(currentProp.trim());
currentProp = '';
} else {
currentProp += char;
}
}
if (currentProp.trim()) styles.push(currentProp.trim());
const jsxStyles = styles
.filter(s => s.includes(':'))
.map(s => {
const colonIndex = s.indexOf(':');
const key = s.substring(0, colonIndex).trim();
const value = s.substring(colonIndex + 1).trim();
if (!key || !value) return '';
const camelKey = key.startsWith('-')
? JSON.stringify(key)
: key.replace(/-([a-z])/g, (g) => g[1].toUpperCase());
let jsxValue;
if (value.startsWith('url(') || value.includes('var(')) {
jsxValue = `'${value.replace(/'/g, "\\'")}'`;
} else if (/^-?\d+(\.\d+)?$/.test(value)) {
jsxValue = value;
} else {
// 转义单引号和反斜杠
const escapedValue = value.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
jsxValue = `'${escapedValue}'`;
}
return `${camelKey}: ${jsxValue}`;
})
.filter(Boolean)
.join(', ');
return `style={{ ${jsxStyles} }}`;
}
/**
* 生成组件代码
*/
function normalizeDisplayName(displayName) {
const text = String(displayName ?? '').trim();
const singleLine = text.replace(/\r?\n/g, ' ');
const safeText = singleLine.replace(/\*\//g, '* /');
return safeText.slice(0, 200);
}
function generateComponent(pageSlug, displayName, bodyContent, headContent) {
const componentName = pageSlug
.split(/[-_\s]+/)
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join('');
const safeDisplayName = normalizeDisplayName(displayName || pageSlug);
let cleanedContent = bodyContent.trim();
if (cleanedContent.startsWith('{/*')) {
const firstTagIndex = cleanedContent.indexOf('<');
if (firstTagIndex > 0) {
cleanedContent = cleanedContent.substring(firstTagIndex);
}
}
const needsWrapper = !isWrappedInSingleElement(cleanedContent);
const finalContent = needsWrapper ? `<>\n${cleanedContent}\n </>` : cleanedContent;
// 生成注入代码
let injectionCode = '';
if (headContent.links.length > 0 || headContent.scripts.length > 0) {
const hasExternalScripts = headContent.scripts.some(s => s.src);
injectionCode = `
// 动态注入外部资源
React.useEffect(function () {
const injected: (HTMLElement)[] = [];
`;
if (headContent.links.length > 0) {
injectionCode += `
// 注入 links
${JSON.stringify(headContent.links)}.forEach(function (linkInfo: any) {
const existing = document.querySelector(\`link[href="\${linkInfo.href}"]\`);
if (!existing) {
const link = document.createElement('link');
link.rel = linkInfo.rel;
link.href = linkInfo.href;
if (linkInfo.crossorigin) link.crossOrigin = 'anonymous';
document.head.appendChild(link);
injected.push(link);
}
});
`;
}
if (hasExternalScripts) {
injectionCode += `
// 注入外部脚本
${JSON.stringify(headContent.scripts.filter(s => s.src))}.forEach(function (scriptInfo: any) {
const existing = document.querySelector(\`script[src="\${scriptInfo.src}"]\`);
if (!existing) {
const script = document.createElement('script');
if (scriptInfo.id) script.id = scriptInfo.id;
script.src = scriptInfo.src;
document.head.appendChild(script);
injected.push(script);
}
});
`;
}
injectionCode += `
return function () {
injected.forEach(function (el) {
if (el.parentNode) el.parentNode.removeChild(el);
});
};
}, []);
`;
}
return `/**
* @name ${safeDisplayName}
*
* 参考资料:
* - /rules/development-guide.md
* - /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,
};

View 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()

View 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
View File

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

View File

@@ -0,0 +1,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;
}

View 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);
}

View 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);
});
}

View 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
View 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;
};

View 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
View 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;
};

View 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);
}

View 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
}
]
}

Binary file not shown.

63
scripts/utils/command-runtime.d.ts vendored Normal file
View File

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

View File

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

View File

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

26
scripts/utils/serverInfo.d.ts vendored Normal file
View 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;

View 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
View 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);
}