初始化项目版本

This commit is contained in:
Axhub Make
2026-07-29 16:04:39 +08:00
commit 4305a1082b
2629 changed files with 760590 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);
}

View File

@@ -0,0 +1,293 @@
/**
* 将版本发布的功能变更说明写入 prototype-registry.json覆盖 commit 131b963 对应条目)。
*
* 用法node scripts/apply-release-changelog.mjs
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, '..');
const REGISTRY_PATH = path.join(projectRoot, 'src/prototypes/oneos-prototype-nav/prototype-registry.json');
const RELEASE_COMMIT = '131b963';
const RELEASE_DATE = '2026-07-12';
const RELEASE_TIME = '22:45';
const MAX_RECENT = 40;
/** @type {Record<string, { summary: string, details: string[] }>} */
const RELEASE_NOTES = {
'oneos-prototype-nav': {
summary: '统一标注壳与操作规范,租赁明细/合同/提车应收款增强,新增任务工单,同步合包页面',
details: [
'【平台】全项目接入 PrototypeAnnotationHostPRD 迁入右侧标注目录;列表统一 OperationActions 与工具栏图标规范',
'【租赁业务明细】收款状态列、47 列宽表、氢费钻取、字段校验、变更记录、导入导出与主管解锁',
'【租赁合同】KPI/状态收敛、查看页、线上/线下签署、交付变更、附加费用与提车应收联动',
'【提车应收款】独立原型上线finance 合包仅保留租赁账单;工作台快捷入口改跳独立原型',
'【任务工单】新增业管发起端:合同/独立双入口、六类任务类型与督办催办',
'【合包】business/ops/finance/ledger-data/lease-contract/h2-station 等移除面包屑并标准化操作区',
'【下线】移除 login/requirements/vehicle-asset 合包;侧栏新增任务工单与提车应收款',
'【发布】导航页固定发布至 /oneos-prototype-nav/index.html',
],
},
'lease-business-detail': {
summary: '租赁业务明细大改版收款状态、47 列宽表、字段校验与变更记录',
details: [
'新增收款状态列(未收款/部分收款/已结清);月份、部门+业务员合并展示;表尾 23 项金额汇总',
'氢费可钻取 Popover编辑/删除/变更记录;公式列(应收合计、未收、盈亏、氢费等)自动重算',
'客户/提车日期/租金等与车辆/客户/合同交叉校验,支持一键替换建议值',
'批量导入 36 列手工字段、导出 47 列;部分成功 + 错误日志;已对账锁定与主管解锁',
],
},
'lease-contract-management': {
summary: '租赁合同增强KPI/状态收敛、查看页、签署方式与提车应收联动',
details: [
'列表 KPI全部/草稿/进行中/审批中/已终止;合同状态收敛;在租车队 KPI 迁至车辆管理',
'筛选:合同模板类型 + 标准合同名称二级联动;统一流程入口(新增/续签/转正式/增车等)',
'新增只读查看页;线上电子签章 / 线下人工上传主动终止与交付安排变更Cascader/TBD',
'附加费用拆分已有/新增服务项;红线条款 + 新增条款检测;先付后用/先用后付',
'提交审批与交付计划变更同步提车应收 bridge',
],
},
'lease-business-ledger': {
summary: '租赁业务台账:操作区统一为 OperationActions标注壳切换',
details: [
'行操作:编辑 / 操作记录 / 保存(编辑态)',
'关联收款、事故/维保/收据链接弹窗保留',
'接入 PrototypeAnnotationHost移除内嵌原型导航目录',
],
},
'self-operated-business-ledger': {
summary: '物流业务明细:更名、列对齐 Excel、批量导入与自动计算',
details: [
'模块更名:物流业务台账 → 物流业务明细',
'列对齐 Excel 1.2:薪资、电费、日社保/挂车/停车/轮胎、线路计价等;系统车型列',
'新增批量导入:模板下载、错误日志、导入后自动计算金额/总成本/盈亏',
'移除行勾选与批量删除',
],
},
'business-dept-ledger': {
summary: '业务部台账:分组表头与表格可读性增强',
details: [
'统一边框 token、分组表头双层样式业务板块 + 业绩/成本/盈亏)',
'冻结列滚动修复;金额列防裁切;盈亏钻取链接样式调整',
],
},
'vehicle-management': {
summary: '车辆管理:在租车队 KPI 迁入、车型分布弹窗与列表样式优化',
details: [
'在租车队 KPI 从租赁合同列表迁入;按品牌型号统计在租数量',
'车型分布弹窗:品牌筛选、车型卡片、占比进度条、车辆明细',
'详情 Tab 与列表筛选/导入/KPI 操作区统一;表格样式优化',
],
},
'customer-management': {
summary: '客户管理OperationActions 统一操作列,移除面包屑',
details: [
'列表操作:详情 / 编辑 / 管理标签 / 删除',
'风险标签、批量打标等业务逻辑不变',
],
},
'contract-template-management': {
summary: '合同模板:启用/停用、先付后用/先用后付变量与操作区重构',
details: [
'行操作:编辑 + 更多(启用/停用、版本日志、删除);启用中不可编辑/删除',
'模板变量新增 paymentMethod付款周期与氢费结算文案更新',
],
},
'insurance-procurement': {
summary: '保险采购OperationActions 统一操作列',
details: [
'保单列表:编辑 / 预览 / 下载 / 更多',
'标注壳切换',
],
},
'vehicle-pickup-receivable': {
summary: '提车应收款独立原型上线:自动生成/手动办理与合同双向联动',
details: [
'收款情况列:租车数 / 已提车Popover 钻取)',
'合同提交审批即生成主表;有交付时间则提前 2 天生成应收',
'手动办理勾选品牌型号与交付时间15 日前/后分档计费规则',
'与租赁合同 bridge 联动finance 合包剥离;工作台入口改跳本原型',
],
},
'task-work-order': {
summary: '任务工单新原型:业管发起端(合同/独立双入口)',
details: [
'任务类型:里程履约、维保履约、政策兑现、付款/交付节点、条款跟进、通用临时',
'列表 KPI全部/我发布的/我督办的 + 状态视角;详情含进度、反馈、催办',
'执行人不可见合同原文与金额(第一版仅业管端)',
],
},
'payment-records': {
summary: '收款记录OperationActions 与关联账单操作',
details: [
'操作列标准化;表格新增「关联账单」',
'标注壳切换',
],
},
'supplier-management': {
summary: '供应商管理:工具栏与操作区图标标准化',
details: [
'新增供应商按钮图标统一;删除收入 OperationActions',
],
},
'oneos-web-business': {
summary: '业务管理合包18 页移除面包屑OperationActions 统一',
details: [
'保险采购、交车任务、客户/供应商、租赁账单、ETC 管理等页操作区标准化',
],
},
'oneos-web-ops': {
summary: '运维管理合包45 页操作列与工具栏按钮标准化',
details: [
'备件/调拨/交还车/故障/异动/证件/型号参数等页 OperationActions 与 data-vm-icon 图标',
],
},
'oneos-web-finance': {
summary: '财务管理合包:提车应收款剥离,仅保留租赁账单',
details: [
'提车应收款 6 页移除;合包默认页改为租赁账单',
'提车流程由 vehicle-pickup-receivable 独立原型承接',
],
},
'oneos-web-ledger-data': {
summary: '台账数据合包5 页操作区标准化',
details: [
'保险分摊、氢费、维修明细、氢费采购端汇总、租赁业务台账等页 OperationActions',
],
},
'oneos-web-lease-contract': {
summary: '租赁合同合包8 子页同步签署/流程字段与 OperationActions',
details: [
'新增、续签、转正式、三方变更、附加费用等子页操作区标准化',
'修复移除面包屑后的 JSX 括号语法',
],
},
'oneos-web-data-analysis': {
summary: '数据分析合包:业务部台账列宽与盈亏着色优化',
details: [
'9 页操作区标准化;业务部台账列宽加大;盈亏负值着色',
],
},
'oneos-web-h2-station': {
summary: '加氢站合包:站点信息编辑提升为主操作',
details: [
'加氢订单/记录/站点信息 3 页 OperationActions站点编辑不再藏在「更多」',
],
},
'oneos-web-workbench': {
summary: '工作台:提车应收款快捷入口改跳独立原型',
details: [
'快捷入口指向 /prototypes/vehicle-pickup-receivable',
'protoNav 支持直接 location 跳转原型 URL',
],
},
'oneos-web-contract-template': {
summary: '合同模板合包:操作区与独立原型对齐',
details: ['模板管理页 OperationActions 统一'],
},
'oneos-web-procurement': {
summary: '采购管理合包:三方退租车页操作区标准化',
details: ['退租申请与管理页 OperationActions'],
},
'oneos-web-h2-station-site': {
summary: '站点信息:标注目录清理',
details: ['移除内嵌原型导航节点;标注壳切换'],
},
'lease-business-line-overview': {
summary: '业务条线说明:标注目录微调',
details: ['条线数据更新;移除内嵌导航节点'],
},
'vehicle-h2-fee-ledger': {
summary: '车辆氢费明细:标注壳与操作区统一',
details: ['OperationActions标注目录规范化'],
},
'vehicle-maintenance-ledger': {
summary: '车辆维修明细:标注壳与操作区统一',
details: ['OperationActions标注目录规范化'],
},
'vehicle-return-settlement-v2': {
summary: '还车应结算 V2OperationActions 统一为详情主操作',
details: ['列表操作列标准化'],
},
};
function readJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
}
function writeJson(filePath, value) {
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
}
function applyReleaseNotes(registry) {
let updated = 0;
for (const [prototypeId, notes] of Object.entries(RELEASE_NOTES)) {
const record = registry.prototypes?.[prototypeId];
if (!record) continue;
const changelog = Array.isArray(record.changelog) ? [...record.changelog] : [];
let targetIndex = changelog.findIndex((entry) => entry.commit === RELEASE_COMMIT);
if (targetIndex < 0 && changelog.length > 0) targetIndex = 0;
const base = targetIndex >= 0 ? changelog[targetIndex] : {
version: record.version,
date: RELEASE_DATE,
time: RELEASE_TIME,
files: record.trackedFiles?.slice(0, 12) || [],
};
const nextEntry = {
...base,
date: RELEASE_DATE,
time: RELEASE_TIME,
summary: notes.summary,
details: notes.details,
commit: RELEASE_COMMIT,
};
if (targetIndex >= 0) {
changelog[targetIndex] = nextEntry;
} else {
changelog.unshift(nextEntry);
}
record.changelog = changelog.slice(0, 30);
record.lastUpdated = `${RELEASE_DATE}T14:45:00.000Z`;
updated += 1;
}
const recent = [];
for (const [prototypeId, notes] of Object.entries(RELEASE_NOTES)) {
const record = registry.prototypes?.[prototypeId];
if (!record) continue;
const entry = record.changelog?.[0];
if (!entry) continue;
recent.push({
prototypeId,
title: record.title || prototypeId,
version: entry.version || record.version,
date: entry.date || RELEASE_DATE,
time: entry.time || RELEASE_TIME,
summary: notes.summary,
details: notes.details,
files: entry.files || [],
});
}
recent.sort((a, b) => {
const ta = new Date(`${a.date}T${a.time}:00+08:00`).getTime();
const tb = new Date(`${b.date}T${b.time}:00+08:00`).getTime();
return tb - ta;
});
registry.recentUpdates = recent.slice(0, MAX_RECENT);
registry.updatedAt = `${RELEASE_DATE}T14:45:00.000Z`;
return updated;
}
const registry = readJson(REGISTRY_PATH);
const count = applyReleaseNotes(registry);
writeJson(REGISTRY_PATH, registry);
console.log(`完成:已写入 ${count} 个原型的版本发布变更说明commit ${RELEASE_COMMIT}`);

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

View File

@@ -0,0 +1,824 @@
#!/usr/bin/env node
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import {
findWorkspaceRoot,
normalizeViewport,
resolveChromiumLaunchOptions,
} from './capture-theme-homepage.mjs';
const PLAYWRIGHT_CACHE_DIR = path.join(os.homedir(), '.cache', 'axure-extractor');
const DEFAULT_SOURCE_CAPTURE_OPTIONS = {
viewport: { width: 1440, height: 900 },
responsiveViewports: [
{ name: 'desktop', width: 1440, height: 900 },
{ name: 'tablet', width: 768, height: 1024 },
{ name: 'mobile', width: 390, height: 844 },
],
deviceScaleFactor: 1,
waitUntil: 'domcontentloaded',
timeoutMs: 60000,
waitAfterLoadMs: 3000,
settleTimeoutMs: 8000,
scrollWarmup: true,
maxScrollSteps: 60,
headless: true,
responsive: true,
screenshot: true,
tokens: true,
hideScrollbar: true,
};
function readRequiredValue(argv, index, flag) {
const value = argv[index + 1];
if (!value || value.startsWith('--')) {
throw new Error(`${flag} requires a value`);
}
return value;
}
function parseInteger(value, name) {
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed)) {
throw new Error(`Invalid ${name}: ${value}`);
}
return parsed;
}
function normalizeSelectorList(value) {
if (!value) return [];
if (Array.isArray(value)) return value.filter(Boolean).map(String);
return [String(value)];
}
function parseHeader(value) {
const separatorIndex = value.includes('=') ? value.indexOf('=') : value.indexOf(':');
if (separatorIndex <= 0) {
throw new Error(`Invalid header, expected Name=Value: ${value}`);
}
const name = value.slice(0, separatorIndex).trim();
const headerValue = value.slice(separatorIndex + 1).trim();
if (!name) throw new Error(`Invalid header, expected Name=Value: ${value}`);
return [name, headerValue];
}
function parseNamedViewport(value, fallbackName) {
const [maybeName, maybeViewport] = value.includes(':')
? value.split(':', 2)
: [fallbackName, value];
const viewport = normalizeViewport(maybeViewport);
return { name: maybeName || fallbackName, ...viewport };
}
export function parseResponsiveViewports(value) {
if (!value) return DEFAULT_SOURCE_CAPTURE_OPTIONS.responsiveViewports;
return String(value)
.split(',')
.map((item, index) => parseNamedViewport(item.trim(), `viewport-${index + 1}`));
}
export function inferThemeFromUrl(url) {
try {
return new URL(url).hostname.replace(/^www\./, '').split('.')[0] || 'homepage';
} catch {
return 'homepage';
}
}
export function parseSourceCaptureArgs(argv = process.argv) {
const args = {
headers: {},
waitForSelectors: [],
dismissSelectors: [],
removeSelectors: [],
help: false,
dryRun: false,
};
const rawArgs = argv.slice(2);
for (let i = 0; i < rawArgs.length; i += 1) {
const flag = rawArgs[i];
if (flag === '--') {
continue;
} else if (flag === '--help' || flag === '-h') {
args.help = true;
} else if (flag === '--dry-run') {
args.dryRun = true;
} else if (flag === '--theme') {
args.theme = readRequiredValue(rawArgs, i, flag);
i += 1;
} else if (flag === '--url') {
args.url = readRequiredValue(rawArgs, i, flag);
i += 1;
} else if (flag === '--output' || flag === '-o') {
args.output = readRequiredValue(rawArgs, i, flag);
i += 1;
} else if (flag === '--viewport') {
args.viewport = normalizeViewport(readRequiredValue(rawArgs, i, flag));
i += 1;
} else if (flag === '--responsive-viewports') {
args.responsiveViewports = parseResponsiveViewports(readRequiredValue(rawArgs, i, flag));
i += 1;
} else if (flag === '--wait-until') {
args.waitUntil = readRequiredValue(rawArgs, i, flag);
i += 1;
} else if (flag === '--timeout') {
args.timeoutMs = parseInteger(readRequiredValue(rawArgs, i, flag), flag);
i += 1;
} else if (flag === '--wait') {
args.waitAfterLoadMs = parseInteger(readRequiredValue(rawArgs, i, flag), flag);
i += 1;
} else if (flag === '--settle-timeout') {
args.settleTimeoutMs = parseInteger(readRequiredValue(rawArgs, i, flag), flag);
i += 1;
} else if (flag === '--max-scroll-steps') {
args.maxScrollSteps = parseInteger(readRequiredValue(rawArgs, i, flag), flag);
i += 1;
} else if (flag === '--selector') {
args.selector = readRequiredValue(rawArgs, i, flag);
i += 1;
} else if (flag === '--wait-for-selector') {
args.waitForSelectors.push(readRequiredValue(rawArgs, i, flag));
i += 1;
} else if (flag === '--dismiss-selector') {
args.dismissSelectors.push(readRequiredValue(rawArgs, i, flag));
i += 1;
} else if (flag === '--remove-selector') {
args.removeSelectors.push(readRequiredValue(rawArgs, i, flag));
i += 1;
} else if (flag === '--header') {
const [name, value] = parseHeader(readRequiredValue(rawArgs, i, flag));
args.headers[name] = value;
i += 1;
} else if (flag === '--connect-cdp') {
args.connectCdp = readRequiredValue(rawArgs, i, flag);
i += 1;
} else if (flag === '--browser-executable') {
args.browserExecutable = readRequiredValue(rawArgs, i, flag);
i += 1;
} else if (flag === '--storage-state') {
args.storageState = readRequiredValue(rawArgs, i, flag);
i += 1;
} else if (flag === '--headless') {
args.headless = true;
} else if (flag === '--no-headless') {
args.headless = false;
} else if (flag === '--scroll-warmup') {
args.scrollWarmup = true;
} else if (flag === '--no-scroll-warmup') {
args.scrollWarmup = false;
} else if (flag === '--responsive') {
args.responsive = true;
} else if (flag === '--no-responsive') {
args.responsive = false;
} else if (flag === '--screenshot') {
args.screenshot = true;
} else if (flag === '--no-screenshot') {
args.screenshot = false;
} else if (flag === '--tokens') {
args.tokens = true;
} else if (flag === '--no-tokens') {
args.tokens = false;
} else if (flag === '--hide-scrollbar') {
args.hideScrollbar = true;
} else if (flag === '--show-scrollbar') {
args.hideScrollbar = false;
} else if (flag.startsWith('--')) {
throw new Error(`Unknown option: ${flag}`);
} else if (!args.url) {
args.url = flag;
} else if (!args.theme) {
args.theme = flag;
} else {
throw new Error(`Unexpected argument: ${flag}`);
}
}
return args;
}
function resolveMaybePath(value, baseDir) {
if (!value) return value;
return path.isAbsolute(value) ? value : path.resolve(baseDir, value);
}
export function resolveSourceCaptureJob(args, options = {}) {
const appRoot = path.resolve(options.appRoot || process.cwd());
const workspaceRoot = path.resolve(options.workspaceRoot || findWorkspaceRoot(appRoot));
const url = args.url;
if (!url) throw new Error('A URL is required. Pass <url> or --url <url>.');
const theme = args.theme || inferThemeFromUrl(url);
const outputDir = resolveMaybePath(
args.output || path.join(appRoot, '.local', `theme-capture-${theme}`),
appRoot,
);
const storageState = resolveMaybePath(args.storageState, appRoot);
return {
...DEFAULT_SOURCE_CAPTURE_OPTIONS,
...Object.fromEntries(Object.entries(args).filter(([, value]) => value !== undefined)),
theme,
url,
appRoot,
workspaceRoot,
outputDir,
storageState,
waitForSelectors: normalizeSelectorList(args.waitForSelectors),
dismissSelectors: normalizeSelectorList(args.dismissSelectors),
removeSelectors: normalizeSelectorList(args.removeSelectors),
headers: args.headers || {},
};
}
async function loadPlaywright() {
try {
return await import('playwright');
} catch {
const cachedPath = path.join(PLAYWRIGHT_CACHE_DIR, 'node_modules', 'playwright', 'index.mjs');
if (fs.existsSync(cachedPath)) {
return import(pathToFileURL(cachedPath).href);
}
}
throw new Error(
'Playwright is not available. Install dependencies with pnpm install, or run an existing capture tool once to populate the shared cache.',
);
}
async function openPage(playwright, job, viewport) {
let browser;
let context;
let page;
let ownsContext = true;
if (job.connectCdp) {
browser = await playwright.chromium.connectOverCDP(job.connectCdp);
context = browser.contexts()[0] || await browser.newContext();
ownsContext = browser.contexts()[0] !== context;
page = await context.newPage();
await page.setViewportSize(viewport);
await context.setExtraHTTPHeaders(job.headers || {});
} else {
browser = await playwright.chromium.launch(resolveChromiumLaunchOptions(job));
context = await browser.newContext({
viewport,
deviceScaleFactor: job.deviceScaleFactor,
storageState: job.storageState && fs.existsSync(job.storageState) ? job.storageState : undefined,
extraHTTPHeaders: job.headers,
ignoreHTTPSErrors: true,
});
page = await context.newPage();
}
return { browser, context, page, ownsContext };
}
async function closePageSession(session, job) {
await session.page.close().catch(() => {});
if (!job.connectCdp && session.ownsContext) {
await session.context.close().catch(() => {});
}
if (!job.connectCdp) {
await session.browser.close().catch(() => {});
}
}
async function injectCaptureCss(page, job) {
const scrollbarCss = job.hideScrollbar
? `
html, body {
scrollbar-width: none !important;
-ms-overflow-style: none !important;
}
html::-webkit-scrollbar,
body::-webkit-scrollbar,
*::-webkit-scrollbar {
width: 0 !important;
height: 0 !important;
display: none !important;
}
`
: '';
const removeCss = (job.removeSelectors || [])
.map((selector) => `${selector} { display: none !important; visibility: hidden !important; }`)
.join('\n');
await page.addStyleTag({
content: `
${scrollbarCss}
* {
caret-color: transparent !important;
}
${removeCss}
`,
});
}
async function dismissOverlays(page, selectors) {
for (const selector of selectors || []) {
try {
await page.locator(selector).first().click({ timeout: 1500 });
await page.waitForTimeout(300);
} catch {
// Optional dismiss selectors should never fail a source capture.
}
}
}
async function waitForSelectors(page, selectors, timeoutMs) {
for (const selector of selectors || []) {
await page.waitForSelector(selector, { timeout: timeoutMs, state: 'attached' });
}
}
async function waitForFontsAndImages(page, timeoutMs) {
await page.evaluate(async (timeout) => {
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const withTimeout = (promise) => Promise.race([promise, delay(timeout)]);
if (document.fonts?.ready) {
await withTimeout(document.fonts.ready);
}
const pendingImages = Array.from(document.images).filter((image) => !image.complete);
await withTimeout(Promise.all(pendingImages.map((image) => new Promise((resolve) => {
image.addEventListener('load', resolve, { once: true });
image.addEventListener('error', resolve, { once: true });
}))));
}, Math.min(timeoutMs, 8000));
}
async function scrollWarmup(page, job) {
return page.evaluate(async ({ maxScrollSteps }) => {
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const scrollingElement = document.scrollingElement || document.documentElement;
const step = Math.max(600, Math.floor(window.innerHeight * 0.85));
let previousHeight = 0;
let stableHeightPasses = 0;
let steps = 0;
while (steps < maxScrollSteps) {
const scrollHeight = scrollingElement.scrollHeight;
if (scrollHeight === previousHeight) stableHeightPasses += 1;
else stableHeightPasses = 0;
previousHeight = scrollHeight;
const nextY = Math.min(window.scrollY + step, scrollHeight - window.innerHeight);
window.scrollTo(0, nextY);
steps += 1;
await delay(220);
if (nextY >= scrollHeight - window.innerHeight && stableHeightPasses >= 2) {
break;
}
}
window.scrollTo(0, 0);
await delay(500);
return { steps, scrollHeight: scrollingElement.scrollHeight };
}, { maxScrollSteps: job.maxScrollSteps });
}
async function preparePage(page, job) {
await page.goto(job.url, { waitUntil: job.waitUntil, timeout: job.timeoutMs });
await injectCaptureCss(page, job);
await waitForSelectors(page, job.waitForSelectors, job.timeoutMs);
await dismissOverlays(page, job.dismissSelectors);
if (job.waitAfterLoadMs > 0) {
await page.waitForTimeout(job.waitAfterLoadMs);
}
await waitForFontsAndImages(page, job.settleTimeoutMs);
const warmup = job.scrollWarmup ? await scrollWarmup(page, job) : null;
await waitForFontsAndImages(page, job.settleTimeoutMs);
await injectCaptureCss(page, job);
return warmup;
}
export function summarizeTheme(theme) {
const summary = {
colors: theme.colors || {},
typography: theme.typography || {},
spacing: theme.spacing || [],
radius: theme.radius || [],
lineWidth: theme.lineWidth || [],
shadow: theme.shadow || {},
transitions: theme.transitions || [],
animations: theme.animations || [],
cssVariables: theme.cssVariables || {},
};
return summary;
}
async function extractThemeTokens(page, job) {
return page.evaluate((selector) => {
const makeBucket = () => new Map();
const add = (bucket, value, tag) => {
if (!value) return;
const trimmed = String(value).trim();
if (!trimmed) return;
if (!bucket.has(trimmed)) bucket.set(trimmed, { count: 0, tags: new Set() });
const item = bucket.get(trimmed);
item.count += 1;
if (tag) item.tags.add(tag);
};
const isZeroish = (value) => {
if (!value) return false;
const trimmed = value.trim();
if (trimmed === '0' || trimmed === '0px' || trimmed === '0%') return true;
const parts = trimmed.split(/\s+/);
return parts.length > 1 && parts.every((part) => part === '0' || part === '0px' || part === '0%');
};
const isTransparentColor = (value) => {
if (!value) return false;
const normalized = value.trim().toLowerCase();
return normalized === 'transparent'
|| normalized === 'rgba(0, 0, 0, 0)'
|| normalized === 'rgba(0,0,0,0)';
};
const normalizeTags = (tags) => (tags.has('body') ? ['body'] : [...tags]);
const sortTopInPage = (bucket, limit = 10) =>
[...bucket.entries()]
.sort((a, b) => b[1].count - a[1].count)
.slice(0, limit)
.map(([value, obj]) => ({ value, count: obj.count, tags: normalizeTags(obj.tags) }));
const sortTextStyles = (bucket, limit = 10) =>
[...bucket.entries()]
.sort((a, b) => b[1].count - a[1].count)
.slice(0, limit)
.map(([value, obj]) => {
const [size, lineHeight, weight, letterSpacing] = value.split('||');
return { size, lineHeight, weight, letterSpacing, count: obj.count, tags: normalizeTags(obj.tags) };
});
const root = selector ? document.querySelector(selector) : document.documentElement;
if (!root) throw new Error(`Selector "${selector}" not found`);
const walkRoot = root.tagName === 'HTML' ? document.body : root;
const walker = document.createTreeWalker(walkRoot, NodeFilter.SHOW_ELEMENT);
const spacingBucket = makeBucket();
const colorBuckets = { background: makeBucket(), text: makeBucket(), border: makeBucket() };
const typographyBuckets = { family: makeBucket(), textStyle: makeBucket() };
const radiusBucket = makeBucket();
const lineWidthBucket = makeBucket();
const shadowBuckets = { box: makeBucket(), text: makeBucket() };
const animationBucket = makeBucket();
const transitionBucket = makeBucket();
const bgImageBucket = makeBucket();
let el = walker.currentNode;
while (el) {
const tag = el.tagName?.toLowerCase() || '';
const rect = el.getBoundingClientRect();
const style = getComputedStyle(el);
const isVisible = rect.width > 0
&& rect.height > 0
&& style.display !== 'none'
&& style.visibility !== 'hidden'
&& Number(style.opacity || 1) !== 0;
if (isVisible) {
if (style.backgroundColor && !isTransparentColor(style.backgroundColor)) {
add(colorBuckets.background, style.backgroundColor, tag);
}
if (style.color && !isTransparentColor(style.color)) {
add(colorBuckets.text, style.color, tag);
}
[style.borderColor, style.borderTopColor, style.borderRightColor, style.borderBottomColor, style.borderLeftColor]
.forEach((color) => {
if (color && !isTransparentColor(color)) add(colorBuckets.border, color, tag);
});
add(typographyBuckets.family, style.fontFamily, tag);
if (style.fontSize && style.fontWeight && style.lineHeight) {
add(typographyBuckets.textStyle, `${style.fontSize}||${style.lineHeight}||${style.fontWeight}||${style.letterSpacing}`, tag);
}
const addSpacingParts = (value) => {
if (!value) return;
value.trim().split(/\s+/).filter(Boolean).forEach((part) => {
if (!isZeroish(part)) add(spacingBucket, part, tag);
});
};
addSpacingParts(style.margin);
addSpacingParts(style.padding);
if (style.gap && style.gap !== 'normal') addSpacingParts(style.gap);
if (style.borderRadius && !isZeroish(style.borderRadius)) add(radiusBucket, style.borderRadius, tag);
['borderWidth', 'borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth'].forEach((widthProp) => {
const value = style[widthProp];
if (value && !isZeroish(value)) add(lineWidthBucket, value, tag);
});
if (style.boxShadow && style.boxShadow !== 'none') add(shadowBuckets.box, style.boxShadow, tag);
if (style.textShadow && style.textShadow !== 'none') add(shadowBuckets.text, style.textShadow, tag);
if (style.animationName && style.animationName !== 'none') {
add(animationBucket, `${style.animationName}|${style.animationDuration}|${style.animationTimingFunction}`, tag);
}
if (style.transition && style.transition !== 'none' && !style.transition.startsWith('all 0s')) {
add(transitionBucket, style.transition, tag);
}
if (style.backgroundImage && style.backgroundImage !== 'none') add(bgImageBucket, style.backgroundImage, tag);
}
el = walker.nextNode();
}
const cssVariables = {};
const rootStyle = getComputedStyle(document.documentElement);
for (const sheet of [...document.styleSheets]) {
try {
for (const rule of [...(sheet.cssRules || [])]) {
if (rule instanceof CSSStyleRule && rule.selectorText === ':root') {
for (const prop of [...rule.style]) {
if (prop.startsWith('--')) cssVariables[prop] = rootStyle.getPropertyValue(prop).trim();
}
}
}
} catch {
// Ignore cross-origin stylesheets.
}
}
const images = [...document.querySelectorAll('img')].map((img) => {
const imgStyle = getComputedStyle(img);
return {
src: img.currentSrc || img.src,
alt: img.alt,
width: img.naturalWidth || null,
height: img.naturalHeight || null,
position: imgStyle.position,
zIndex: imgStyle.zIndex,
siblingImgCount: img.parentElement ? img.parentElement.querySelectorAll('img').length : 0,
};
});
return {
colors: {
background: sortTopInPage(colorBuckets.background, 10),
text: sortTopInPage(colorBuckets.text, 10),
border: sortTopInPage(colorBuckets.border, 8),
},
typography: {
families: sortTopInPage(typographyBuckets.family, 8),
textStyles: sortTextStyles(typographyBuckets.textStyle, 12),
},
spacing: sortTopInPage(spacingBucket, 18),
radius: sortTopInPage(radiusBucket, 8),
lineWidth: sortTopInPage(lineWidthBucket, 6),
shadow: {
box: sortTopInPage(shadowBuckets.box, 8),
text: sortTopInPage(shadowBuckets.text, 4),
},
animations: sortTopInPage(animationBucket, 8),
transitions: sortTopInPage(transitionBucket, 8),
cssVariables,
assets: {
backgroundImages: sortTopInPage(bgImageBucket, 12),
images,
svgCount: document.querySelectorAll('svg').length,
},
};
}, job.selector || null);
}
async function extractSamples(page) {
return page.evaluate(() => {
const rgbaToHex = (value) => {
const match = String(value || '').match(/rgba?\(([^)]+)\)/);
if (!match) return value;
const parts = match[1].split(',').map((part) => part.trim());
const [r, g, b] = parts.slice(0, 3).map(Number);
const alpha = parts[3] === undefined ? 1 : Number(parts[3]);
const hex = [r, g, b]
.map((num) => Math.max(0, Math.min(255, Math.round(num))).toString(16).padStart(2, '0'))
.join('');
return alpha === 1 ? `#${hex}` : `#${hex}${Math.round(alpha * 255).toString(16).padStart(2, '0')}`;
};
const sampleSelectors = ['header', 'nav', 'h1', 'h2', 'h3', 'button', 'a[href]', 'input', 'textarea', 'main section', 'footer'];
return sampleSelectors.flatMap((selector) => [...document.querySelectorAll(selector)].slice(0, 10).map((el) => {
const style = getComputedStyle(el);
const rect = el.getBoundingClientRect();
return {
selector,
tag: el.tagName.toLowerCase(),
text: (el.innerText || el.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 180),
color: rgbaToHex(style.color),
background: rgbaToHex(style.backgroundColor),
border: `${style.borderTopWidth} ${style.borderTopStyle} ${rgbaToHex(style.borderTopColor)}`,
radius: style.borderTopLeftRadius,
fontFamily: style.fontFamily,
fontSize: style.fontSize,
lineHeight: style.lineHeight,
fontWeight: style.fontWeight,
letterSpacing: style.letterSpacing,
padding: `${style.paddingTop} ${style.paddingRight} ${style.paddingBottom} ${style.paddingLeft}`,
margin: `${style.marginTop} ${style.marginRight} ${style.marginBottom} ${style.marginLeft}`,
width: Math.round(rect.width),
height: Math.round(rect.height),
};
}));
});
}
async function captureViewport(playwright, job, viewport, outputPath, { includeTokens = false } = {}) {
const session = await openPage(playwright, job, { width: viewport.width, height: viewport.height });
const startedAt = Date.now();
try {
const warmup = await preparePage(session.page, job);
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
if (job.screenshot) {
await session.page.screenshot({
path: outputPath,
fullPage: true,
type: 'png',
animations: 'disabled',
caret: 'hide',
timeout: job.timeoutMs,
});
}
const state = await session.page.evaluate(() => {
const scrollingElement = document.scrollingElement || document.documentElement;
return {
url: window.location.href,
title: document.title,
viewport: { width: window.innerWidth, height: window.innerHeight },
scrollHeight: scrollingElement.scrollHeight,
nodeCount: document.querySelectorAll('*').length,
};
});
let theme = null;
let samples = [];
if (includeTokens && job.tokens) {
theme = await extractThemeTokens(session.page, job);
samples = await extractSamples(session.page);
}
return {
...state,
name: viewport.name,
screenshot: job.screenshot ? outputPath : null,
warmup,
durationMs: Date.now() - startedAt,
theme,
samples,
};
} finally {
await closePageSession(session, job);
}
}
async function captureSourceJob(playwright, job) {
fs.mkdirSync(job.outputDir, { recursive: true });
const responsiveDir = path.join(job.outputDir, 'responsive');
const viewport = { name: 'desktop', ...job.viewport };
const mainScreenshotPath = path.join(job.outputDir, 'screenshot.png');
const main = await captureViewport(playwright, job, viewport, mainScreenshotPath, { includeTokens: true });
const responsive = {};
if (job.responsive) {
fs.mkdirSync(responsiveDir, { recursive: true });
for (const item of job.responsiveViewports) {
const screenshotPath = path.join(responsiveDir, `${item.name}.png`);
responsive[item.name] = await captureViewport(playwright, job, item, screenshotPath, {
includeTokens: false,
});
}
}
const metadata = {
tool: 'capture-theme-source',
theme: job.theme,
requestedUrl: job.url,
finalUrl: main.url,
title: main.title,
viewport: job.viewport,
responsiveViewports: job.responsiveViewports,
outputDir: job.outputDir,
files: {
screenshot: job.screenshot ? 'screenshot.png' : null,
responsive: job.responsive ? Object.fromEntries(Object.keys(responsive).map((name) => [name, `responsive/${name}.png`])) : {},
theme: job.tokens ? 'theme.json' : null,
computedTokens: job.tokens ? 'computed-tokens.json' : null,
},
page: {
scrollHeight: main.scrollHeight,
nodeCount: main.nodeCount,
},
responsive: Object.fromEntries(Object.entries(responsive).map(([name, value]) => [
name,
{
url: value.url,
title: value.title,
viewport: value.viewport,
scrollHeight: value.scrollHeight,
nodeCount: value.nodeCount,
screenshot: path.relative(job.outputDir, value.screenshot),
},
])),
captureOptions: {
waitUntil: job.waitUntil,
waitAfterLoadMs: job.waitAfterLoadMs,
scrollWarmup: job.scrollWarmup,
selector: job.selector || null,
waitForSelectors: job.waitForSelectors,
dismissSelectors: job.dismissSelectors,
removeSelectors: job.removeSelectors,
connectCdp: Boolean(job.connectCdp),
browserExecutable: Boolean(job.browserExecutable),
storageState: Boolean(job.storageState),
},
capturedAt: new Date().toISOString(),
};
if (job.tokens && main.theme) {
fs.writeFileSync(path.join(job.outputDir, 'theme.json'), JSON.stringify(main.theme, null, 2));
fs.writeFileSync(path.join(job.outputDir, 'computed-tokens.json'), JSON.stringify({
page: {
url: main.url,
title: main.title,
viewport: main.viewport,
scrollHeight: main.scrollHeight,
nodeCount: main.nodeCount,
},
responsive: metadata.responsive,
tokens: summarizeTheme(main.theme),
samples: main.samples,
}, null, 2));
}
fs.writeFileSync(path.join(job.outputDir, 'meta.json'), JSON.stringify(metadata, null, 2));
return metadata;
}
function showHelp() {
console.log(`
Capture source evidence for building an Axhub Make theme.
Usage:
pnpm exec node scripts/capture-theme-source.mjs https://example.com --theme example
pnpm exec node scripts/capture-theme-source.mjs --url https://example.com --theme example -o .local/theme-capture-example
Outputs:
.local/theme-capture-<theme>/
screenshot.png
responsive/desktop.png
responsive/tablet.png
responsive/mobile.png
theme.json
computed-tokens.json
meta.json
Options:
--theme NAME Theme key used for the default output path
--url URL Source URL
-o, --output DIR Output directory. Default: .local/theme-capture-<theme>
--viewport WxH Desktop viewport. Default: 1440x900
--responsive-viewports LIST Comma list: desktop:1440x900,tablet:768x1024,mobile:390x844
--selector SEL Scope token extraction to a selector
--wait MS Extra wait after load. Default: 3000
--wait-until STATE load, domcontentloaded, or networkidle. Default: domcontentloaded
--wait-for-selector SEL Wait for selector before capture. Repeatable
--dismiss-selector SEL Click optional overlay accept/close selector. Repeatable
--remove-selector SEL Hide noisy selector before capture. Repeatable
--connect-cdp URL Reuse a running Chrome, for example http://localhost:9222
--browser-executable PATH Use a specific Chrome/Chromium executable
--storage-state FILE Playwright storageState JSON
--no-responsive Skip responsive screenshots
--no-screenshot Skip screenshot files
--no-tokens Skip theme.json and computed-tokens.json
--dry-run Print resolved job without opening a browser
`);
}
async function runCli() {
const args = parseSourceCaptureArgs(process.argv);
if (args.help) {
showHelp();
return;
}
const job = resolveSourceCaptureJob(args);
if (args.dryRun) {
console.log(JSON.stringify(job, null, 2));
return;
}
const playwright = await loadPlaywright();
console.log(`[theme-source] ${job.theme} ${job.url}`);
const metadata = await captureSourceJob(playwright, job);
console.log(`[theme-source] wrote ${metadata.outputDir}`);
console.log('[theme-source] files: screenshot.png, responsive/, theme.json, computed-tokens.json, meta.json');
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
runCli().catch((error) => {
console.error(`[theme-source] ${error.stack || error.message || error}`);
process.exitCode = 1;
});
}

View File

@@ -0,0 +1,105 @@
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import {
inferThemeFromUrl,
parseResponsiveViewports,
parseSourceCaptureArgs,
resolveSourceCaptureJob,
summarizeTheme,
} from './capture-theme-source.mjs';
describe('capture-theme-source CLI planning', () => {
it('parses source capture options for screenshot and token evidence', () => {
const args = parseSourceCaptureArgs([
'node',
'capture-theme-source.mjs',
'https://www.trae.ai/',
'--theme',
'trae-ai',
'--output',
'.local/theme-capture-trae-ai',
'--viewport',
'1440x900',
'--responsive-viewports',
'desktop:1440x900,tablet:768x1024,mobile:390x844',
'--wait-for-selector',
'main',
'--dismiss-selector',
'button:has-text("Accept")',
'--remove-selector',
'.cookie-banner',
'--header',
'Authorization=Bearer token',
'--connect-cdp',
'http://localhost:9222',
'--no-scroll-warmup',
]);
expect(args.url).toBe('https://www.trae.ai/');
expect(args.theme).toBe('trae-ai');
expect(args.output).toBe('.local/theme-capture-trae-ai');
expect(args.viewport).toEqual({ width: 1440, height: 900 });
expect(args.responsiveViewports).toEqual([
{ name: 'desktop', width: 1440, height: 900 },
{ name: 'tablet', width: 768, height: 1024 },
{ name: 'mobile', width: 390, height: 844 },
]);
expect(args.waitForSelectors).toEqual(['main']);
expect(args.dismissSelectors).toEqual(['button:has-text("Accept")']);
expect(args.removeSelectors).toEqual(['.cookie-banner']);
expect(args.headers).toEqual({ Authorization: 'Bearer token' });
expect(args.connectCdp).toBe('http://localhost:9222');
expect(args.scrollWarmup).toBe(false);
});
it('resolves default output directory from a URL-derived theme key', () => {
const appRoot = '/repo/client';
const workspaceRoot = '/repo';
const args = parseSourceCaptureArgs([
'node',
'capture-theme-source.mjs',
'https://www.trae.ai/',
'--dry-run',
]);
const job = resolveSourceCaptureJob(args, { appRoot, workspaceRoot });
expect(job.theme).toBe('trae');
expect(job.outputDir).toBe(path.join(appRoot, '.local/theme-capture-trae'));
expect(job.viewport).toEqual({ width: 1440, height: 900 });
expect(job.responsive).toBe(true);
expect(job.screenshot).toBe(true);
expect(job.tokens).toBe(true);
});
it('normalizes responsive viewport lists and URL theme inference', () => {
expect(parseResponsiveViewports('wide:1600x900,narrow:375x812')).toEqual([
{ name: 'wide', width: 1600, height: 900 },
{ name: 'narrow', width: 375, height: 812 },
]);
expect(inferThemeFromUrl('https://www.example.co.uk/path')).toBe('example');
});
it('keeps computed token summaries focused on theme evidence', () => {
const summary = summarizeTheme({
colors: { text: [{ value: 'rgb(255, 255, 255)', count: 2 }] },
typography: { families: [{ value: 'Inter', count: 3 }] },
spacing: [{ value: '16px', count: 4 }],
ignored: 'not included',
});
expect(summary).toEqual({
colors: { text: [{ value: 'rgb(255, 255, 255)', count: 2 }] },
typography: { families: [{ value: 'Inter', count: 3 }] },
spacing: [{ value: '16px', count: 4 }],
radius: [],
lineWidth: [],
shadow: {},
transitions: [],
animations: [],
cssVariables: {},
});
});
});

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

256
scripts/convert-word-doc.py Normal file
View File

@@ -0,0 +1,256 @@
#!/usr/bin/env python3
"""Convert textutil Word HTML export to ct-word-doc seed JS module."""
import argparse
import json
import re
import subprocess
import tempfile
from pathlib import Path
from typing import Dict, List, Optional, Set
from bs4 import BeautifulSoup, NavigableString, Tag
DEFAULT_SEMANTIC = {
"p1": "ct-word-title",
"p2": "ct-word-contract-no",
"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 DEFAULT_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
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)
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, semantic: Dict[str, str]) -> 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"]
for td in table.find_all("td"):
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}} 】"),
(r"协议编号【LNZLHT[^】]*】", "协议编号【LNZLHT {{contractCode}} 】"),
]
literal_replacements = [
("甲方(出租方):羚牛氢能科技(广东)有限公司", "甲方(出租方):{{lessorName}}"),
("甲方(出租方): 羚牛氢能科技(广东)有限公司", "甲方(出租方): {{lessorName}}"),
("甲方:羚牛氢能科技(广东)有限公司", "甲方:{{lessorName}}"),
("致:羚牛氢能科技(广东)有限公司", "致:{{lessorName}}"),
("甲方(车辆提供方):", "甲方(车辆提供方):{{lessorName}}"),
("乙方(车辆使用方):", "乙方(车辆使用方):{{customerName}}"),
("乙方(承租方):</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_html(raw: str) -> str:
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")
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, DEFAULT_SEMANTIC)
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=\"p\d+\"><br\s*/?></p>\s*", "", body_html, count=3)
return (
'<div class="ct-word-doc ct-word-doc--v266">'
f"<style>{scoped}</style>"
f"{body_html}"
"</div>"
)
def export_docx(docx: Path, out: Path, export_name: str) -> None:
with tempfile.NamedTemporaryFile(suffix=".html", delete=False) as tmp:
tmp_path = Path(tmp.name)
subprocess.run(
["textutil", "-convert", "html", "-output", str(tmp_path), str(docx)],
check=True,
)
raw = tmp_path.read_text(encoding="utf-8")
tmp_path.unlink(missing_ok=True)
html = convert_html(raw)
escaped = json.dumps(html, ensure_ascii=False)
out.write_text(
f"// AUTO-GENERATED from {docx.name} — do not edit by hand\n"
f"export var {export_name} = {escaped};\n",
encoding="utf-8",
)
print(f"Wrote {out} ({out.stat().st_size} bytes), redlines={html.count('data-risk-redline')}")
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("docx", type=Path)
parser.add_argument("out", type=Path)
parser.add_argument("export_name")
args = parser.parse_args()
export_docx(args.docx, args.out, args.export_name)
if __name__ == "__main__":
main()

118
scripts/cursor-usage-snapshot.py Executable file
View File

@@ -0,0 +1,118 @@
#!/usr/bin/env python3
"""本地用量快照:不消耗 Cursor Agent token直接读官方 API。"""
from __future__ import annotations
import datetime as dt
import json
import sqlite3
import urllib.request
from collections import defaultdict
STATE_DB = "/Users/sylvawong/Library/Application Support/Cursor/User/globalStorage/state.vscdb"
TZ = dt.timezone(dt.timedelta(hours=8))
def token() -> str:
conn = sqlite3.connect(STATE_DB)
try:
row = conn.execute(
"SELECT value FROM ItemTable WHERE key='cursorAuth/accessToken'"
).fetchone()
finally:
conn.close()
if not row:
raise SystemExit("未找到 Cursor accessToken请先在 Cursor 登录。")
return row[0]
def post(path: str, body: dict | None = None) -> dict:
req = urllib.request.Request(
f"https://api2.cursor.sh/{path}",
data=json.dumps(body or {}).encode(),
headers={
"Authorization": f"Bearer {token()}",
"Content-Type": "application/json",
"Connect-Protocol-Version": "1",
"User-Agent": "oneos-token-budget/1.0",
},
method="POST",
)
with urllib.request.urlopen(req, timeout=60) as resp:
return json.loads(resp.read().decode() or "{}")
def ms_to_local(ms: str | int) -> str:
return dt.datetime.fromtimestamp(int(ms) / 1000, TZ).strftime("%Y-%m-%d %H:%M")
def main() -> None:
usage = post("aiserver.v1.DashboardService/GetCurrentPeriodUsage")
plan = post("aiserver.v1.DashboardService/GetPlanInfo")
pu = usage.get("planUsage") or {}
info = plan.get("planInfo") or {}
print("=== Cursor 用量快照 ===")
print(f"套餐: {info.get('planName')} ({info.get('price')})")
print(f"账期: {ms_to_local(usage.get('billingCycleStart'))}{ms_to_local(usage.get('billingCycleEnd'))}")
print(f"Auto+Composer: {float(pu.get('autoPercentUsed') or 0):.2f}%")
print(f"API: {float(pu.get('apiPercentUsed') or 0):.2f}%")
print(f"综合 total: {float(pu.get('totalPercentUsed') or 0):.2f}%")
print(
f"等价花费: ${float(pu.get('totalSpend') or 0)/100:.2f} / ${float(pu.get('limit') or 0)/100:.2f}"
)
# Today since 00:00
now = dt.datetime.now(TZ)
start = now.replace(hour=0, minute=0, second=0, microsecond=0)
data = post(
"aiserver.v1.DashboardService/GetFilteredUsageEvents",
{
"startDate": str(int(start.timestamp() * 1000)),
"endDate": str(int(now.timestamp() * 1000)),
"page": 1,
"pageSize": 200,
},
)
events = list(data.get("usageEventsDisplay") or [])
total = int(data.get("totalUsageEventsCount") or 0)
pages = max(1, (total + 199) // 200)
for page in range(2, pages + 1):
more = post(
"aiserver.v1.DashboardService/GetFilteredUsageEvents",
{
"startDate": str(int(start.timestamp() * 1000)),
"endDate": str(int(now.timestamp() * 1000)),
"page": page,
"pageSize": 200,
},
)
events.extend(more.get("usageEventsDisplay") or [])
tot = defaultdict(int)
by_model = defaultdict(lambda: defaultdict(int))
for e in events:
tu = e.get("tokenUsage") or {}
inp = int(tu.get("inputTokens") or 0)
out = int(tu.get("outputTokens") or 0)
cache = int(tu.get("cacheReadTokens") or 0)
model = e.get("model") or "unknown"
tot["events"] += 1
tot["input"] += inp
tot["output"] += out
tot["cache"] += cache
tot["all"] += inp + out + cache
tot["io"] += inp + out
by_model[model]["all"] += inp + out + cache
by_model[model]["events"] += 1
print(f"\n=== 今日(自 {start.strftime('%H:%M')}===")
print(f"请求: {tot['events']:,}")
print(f"输入+输出: {tot['io']:,}")
print(f"含缓存合计: {tot['all']:,}")
print("按模型:")
for model, b in sorted(by_model.items(), key=lambda kv: -kv[1]["all"]):
print(f" - {model}: {b['events']} 次, 含缓存 {b['all']:,}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,339 @@
/**
* 从 Git 提交历史生成/补全原型导航注册表中的详细变更日志。
*
* 用法:
* node scripts/enrich-prototype-registry-from-git.mjs
* node scripts/enrich-prototype-registry-from-git.mjs --prototype vehicle-h2-fee-ledger
*/
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, '..');
const REGISTRY_PATH = path.join(projectRoot, 'src/prototypes/oneos-prototype-nav/prototype-registry.json');
const TRACKED_EXTENSIONS = new Set(['.tsx', '.ts', '.jsx', '.js', '.css', '.json', '.md', '.html']);
const IGNORED_FILE_NAMES = new Set(['prototype-registry.json', 'nav-menu.json']);
const MAX_CHANGELOG = 30;
const MAX_RECENT = 40;
function parseArgs(argv) {
const args = { prototype: '' };
for (let i = 0; i < argv.length; i += 1) {
if (argv[i] === '--prototype' && argv[i + 1]) args.prototype = argv[++i].trim();
}
return args;
}
function shouldTrackFile(relativePath) {
const base = path.basename(relativePath);
if (IGNORED_FILE_NAMES.has(base)) return false;
if (relativePath === 'annotation-source.json') return false;
return TRACKED_EXTENSIONS.has(path.extname(relativePath).toLowerCase());
}
function normalizePrototypePath(filePath, prototypeId) {
const normalized = String(filePath || '').replace(/\\/gu, '/').trim();
const prefix = `src/prototypes/${prototypeId}/`;
if (normalized.startsWith(prefix)) return normalized.slice(prefix.length);
return null;
}
function parseCommitBlocks(raw) {
const blocks = raw.split(/\n?----\n/u).map((block) => block.trim()).filter(Boolean);
const commits = [];
for (const block of blocks) {
const lines = block.split('\n');
const hash = lines[0]?.trim();
if (!/^[0-9a-f]{7,40}$/iu.test(hash || '')) continue;
const dateLine = lines[1]?.trim();
const subject = lines[2]?.trim() || '';
if (!dateLine) continue;
const fileStart = lines.findIndex((line, index) => index > 2 && line.trim() === '');
const bodyEnd = fileStart >= 0 ? fileStart : lines.length;
const body = lines.slice(3, bodyEnd).join('\n').trim();
const files = (fileStart >= 0 ? lines.slice(fileStart + 1) : [])
.map((line) => line.trim())
.filter(Boolean);
commits.push({ hash, dateLine, subject, body, files });
}
return commits;
}
function getPrototypeCommits(prototypeId) {
const relDir = `src/prototypes/${prototypeId}`;
if (!fs.existsSync(path.join(projectRoot, relDir))) return [];
let raw = '';
try {
raw = execSync(
`git log --pretty=format:----%n%H%n%ci%n%s%n%b --name-only -- ${relDir}`,
{ cwd: projectRoot, encoding: 'utf8', maxBuffer: 20 * 1024 * 1024 },
);
} catch {
return [];
}
return parseCommitBlocks(raw);
}
function formatDateParts(dateLine) {
const match = dateLine.match(/^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2})/u);
if (!match) {
const date = new Date(dateLine);
if (Number.isNaN(date.getTime())) return { date: '1970-01-01', time: '00:00', iso: null };
return {
date: date.toISOString().slice(0, 10),
time: `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`,
iso: date.toISOString(),
};
}
const [, y, m, d, hh, mm] = match;
return {
date: `${y}-${m}-${d}`,
time: `${hh}:${mm}`,
iso: new Date(`${y}-${m}-${d}T${hh}:${mm}:00+08:00`).toISOString(),
};
}
function extractBodyLines(body) {
return body
.split('\n')
.map((line) => line.trim())
.filter((line) => line && !/^Co-authored-by:/iu.test(line))
.map((line) => line.replace(/^[-*•]\s*/u, '').trim())
.filter(Boolean);
}
function strictKeywords(title, prototypeId) {
const keywords = new Set([title, prototypeId]);
for (const part of String(title || '').split(/[·、/()\s「」]+/u)) {
const trimmed = part.trim();
if (trimmed.length >= 3) keywords.add(trimmed);
}
if (title.includes('氢费明细')) keywords.add('氢费明细');
else if (title.includes('氢费')) keywords.add('氢费');
if (title.includes('原型导航')) keywords.add('原型导航');
if (title.includes('合同模板')) keywords.add('合同模板');
if (title.includes('租赁合同')) keywords.add('租赁合同');
if (title.includes('租赁业务')) keywords.add('租赁业务');
if (title.includes('收款记录')) keywords.add('收款记录');
if (title.includes('维修保养')) keywords.add('维修保养');
if (title.includes('还车应结')) keywords.add('还车应结');
return [...keywords];
}
function lineMatchesPrototype(line, title, prototypeId) {
if (!line) return false;
if (line.includes(title)) return true;
if (line.toLowerCase().includes(prototypeId.toLowerCase())) return true;
return strictKeywords(title, prototypeId).some((keyword) => (
keyword.length >= 3 && line.includes(keyword)
));
}
function commitIsRelevant(commit, title, prototypeId, changedFiles, allChangedFiles, bodyLines) {
if (changedFiles.length > 0) return true;
const onlyAnnotation = allChangedFiles.length > 0
&& allChangedFiles.every((file) => file === 'annotation-source.json');
const textMatches = [commit.subject, ...bodyLines].some((line) => (
lineMatchesPrototype(line, title, prototypeId)
));
if (textMatches && onlyAnnotation) return true;
if (textMatches) return true;
return false;
}
function summarizeFileChanges(files) {
const notes = [];
const prd = files.filter((f) => f.includes('.spec/') && f.endsWith('.md'));
const comments = files.filter((f) => f.includes('prototype-comments') || f.includes('annotation-source'));
const styles = files.filter((f) => f.endsWith('.css'));
const logic = files.filter((f) => /\.(tsx|jsx|ts|js)$/u.test(f) && !f.endsWith('.css'));
const data = files.filter((f) => f.includes('/data/') || f.endsWith('.json'));
if (logic.length) {
const names = [...new Set(logic.map((f) => path.basename(f)))].slice(0, 4);
notes.push(`页面逻辑与交互:${names.join('、')}${logic.length > names.length ? ' 等' : ''}`);
}
if (styles.length) notes.push(`样式与布局:${[...new Set(styles.map((f) => path.basename(f)))].join('、')}`);
if (prd.length) notes.push(`需求说明与 PRD${prd.map((f) => path.basename(f)).join('、')}`);
if (comments.length) notes.push('原型标注目录与批注说明同步');
if (data.length) notes.push(`演示数据与配置:${data.map((f) => path.basename(f)).join('、')}`);
return notes;
}
function buildSummary(subject, prototypeId, title, bodyLines, changedFiles, onlyAnnotation) {
const matched = bodyLines.filter((line) => lineMatchesPrototype(line, title, prototypeId));
if (matched.length === 1) return matched[0];
if (matched.length > 1) return matched[0];
const fragments = splitSubjectFragments(subject, prototypeId, title);
if (fragments.length === 1) return fragments[0];
if (fragments.length > 1) return fragments[0];
if (onlyAnnotation && changedFiles.length === 0) return '原型标注目录同步';
if (changedFiles.length) return summarizeFileChanges(changedFiles)[0] || subject;
if (lineMatchesPrototype(subject, title, prototypeId)) return subject;
return subject;
}
function dedupeDetails(summary, details) {
return [...new Set(details)].filter((line) => (
line
&& line !== summary
&& !summary.includes(line)
&& line.length > 2
));
}
function splitSubjectFragments(subject, prototypeId, title) {
const segments = subject
.split(/[;]/u)
.map((part) => part.trim())
.filter(Boolean);
const matched = segments.filter((segment) => lineMatchesPrototype(segment, title, prototypeId));
if (!matched.length) return [];
const fragments = [];
for (const segment of matched) {
const colonIndex = segment.indexOf('');
const tail = colonIndex >= 0 ? segment.slice(colonIndex + 1) : segment;
tail.split(/[、,]/u)
.map((part) => part.trim())
.filter((part) => part.length >= 4)
.forEach((part) => fragments.push(part));
}
return [...new Set(fragments)];
}
function buildDetails(subject, bodyLines, prototypeId, title, changedFiles) {
const details = [];
const matched = bodyLines.filter((line) => lineMatchesPrototype(line, title, prototypeId));
details.push(...matched);
for (const fragment of splitSubjectFragments(subject, prototypeId, title)) {
if (!details.includes(fragment) && fragment !== subject) details.push(fragment);
}
for (const note of summarizeFileChanges(changedFiles)) {
if (!details.includes(note)) details.push(note);
}
if (!details.length && changedFiles.length) {
details.push(`变更 ${changedFiles.length} 个文件`);
}
return details.filter((line) => line !== subject);
}
function bumpVersion(indexFromOldest) {
return `v1.${indexFromOldest}`;
}
function buildChangelogFromGit(prototypeId, title) {
const commits = getPrototypeCommits(prototypeId);
if (!commits.length) return [];
const chronological = [...commits].reverse();
const entries = [];
for (const commit of chronological) {
const { date, time } = formatDateParts(commit.dateLine);
const bodyLines = extractBodyLines(commit.body);
const changedFiles = commit.files
.map((file) => normalizePrototypePath(file, prototypeId))
.filter((file) => file && shouldTrackFile(file));
const allChangedFiles = commit.files
.map((file) => normalizePrototypePath(file, prototypeId))
.filter(Boolean);
const onlyAnnotation = allChangedFiles.length > 0
&& allChangedFiles.every((file) => file === 'annotation-source.json');
if (!commitIsRelevant(commit, title, prototypeId, changedFiles, allChangedFiles, bodyLines)) {
continue;
}
const summary = buildSummary(commit.subject, prototypeId, title, bodyLines, changedFiles, onlyAnnotation);
const details = dedupeDetails(
summary,
buildDetails(commit.subject, bodyLines, prototypeId, title, changedFiles),
);
entries.push({
version: '',
date,
time,
summary,
details,
commit: commit.hash.slice(0, 7),
files: [...new Set(changedFiles)].slice(0, 12),
});
}
return entries.reverse().map((entry, index, list) => ({
...entry,
version: bumpVersion(list.length - index),
}));
}
function rebuildRecentUpdates(registry) {
const rows = [];
for (const [prototypeId, record] of Object.entries(registry.prototypes || {})) {
const latest = record.changelog?.[0];
if (!latest) continue;
rows.push({
prototypeId,
title: record.title || prototypeId,
version: latest.version,
date: latest.date,
time: latest.time,
summary: latest.summary,
details: latest.details || [],
files: latest.files || [],
commit: latest.commit,
_sort: `${latest.date}T${latest.time}`,
});
}
rows.sort((a, b) => b._sort.localeCompare(a._sort));
return rows.slice(0, MAX_RECENT).map(({ _sort, ...row }) => row);
}
function main() {
const args = parseArgs(process.argv.slice(2));
const registry = JSON.parse(fs.readFileSync(REGISTRY_PATH, 'utf8'));
const prototypeIds = args.prototype
? [args.prototype]
: Object.keys(registry.prototypes || {});
let updated = 0;
for (const prototypeId of prototypeIds) {
const record = registry.prototypes[prototypeId];
if (!record) continue;
const changelog = buildChangelogFromGit(prototypeId, record.title || prototypeId);
if (!changelog.length) continue;
record.changelog = changelog.slice(0, MAX_CHANGELOG);
record.revision = changelog.length;
record.version = changelog[0].version;
const latestIso = getPrototypeCommits(prototypeId)[0];
if (latestIso) {
const { iso } = formatDateParts(latestIso.dateLine);
if (iso) record.lastUpdated = iso;
}
registry.prototypes[prototypeId] = record;
updated += 1;
console.log(`[git] ${prototypeId}: ${changelog.length} entries → ${changelog[0].version}`);
}
registry.recentUpdates = rebuildRecentUpdates(registry);
registry.updatedAt = new Date().toISOString();
fs.writeFileSync(REGISTRY_PATH, `${JSON.stringify(registry, null, 2)}\n`, 'utf8');
console.log(`完成:补全 ${updated} 个原型的 Git 变更日志`);
}
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);
}

View File

@@ -0,0 +1,14 @@
/**
* 租赁合同主题比稿:兼容旧 import 路径,实现见 design-system/theme-variants。
*/
export {
THEME_SHOTS,
THEME_CATALOG,
OFFICIAL_THEME,
buildSkinCss,
buildAntThemeConfig,
buildOfficialAntTheme,
getThemeById,
resolvePalette,
resolvePrimary,
} from '../src/resources/design-system/theme-variants/build-skin.mjs';

View File

@@ -0,0 +1,167 @@
#!/usr/bin/env node
/**
* 合并 merge 冲突中的 prototype-registry.json 与 sidebar-tree.json保留两侧内容
* 用法node scripts/merge-registry-sidebar-conflicts.mjs
*/
import fs from 'node:fs';
import path from 'node:path';
import { execSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, '..');
function gitShow(stage, filePath) {
return execSync(`git show :${stage}:${filePath}`, {
cwd: projectRoot,
encoding: 'utf8',
});
}
function readJsonFromGit(stage, relativePath) {
return JSON.parse(gitShow(stage, relativePath));
}
function writeJson(relativePath, value) {
const abs = path.join(projectRoot, relativePath);
fs.writeFileSync(abs, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
}
function collectItemKeys(items, acc = new Set()) {
for (const item of items || []) {
if (item?.itemKey) acc.add(item.itemKey);
if (Array.isArray(item?.children)) collectItemKeys(item.children, acc);
}
return acc;
}
function findItemByKey(items, itemKey) {
for (const item of items || []) {
if (item?.itemKey === itemKey) return item;
if (Array.isArray(item?.children)) {
const found = findItemByKey(item.children, itemKey);
if (found) return found;
}
}
return null;
}
function findFolder(items, title) {
for (const item of items || []) {
if (item?.kind === 'folder' && item?.title === title) return item;
if (Array.isArray(item?.children)) {
const found = findFolder(item.children, title);
if (found) return found;
}
}
return null;
}
function ensureChildFolder(parent, folderTitle, folderId) {
parent.children = parent.children || [];
let folder = parent.children.find((c) => c.kind === 'folder' && c.title === folderTitle);
if (!folder) {
folder = {
id: folderId,
kind: 'folder',
title: folderTitle,
children: [],
};
parent.children.push(folder);
}
folder.children = folder.children || [];
return folder;
}
function ensureItem(folder, item) {
if (!item?.itemKey) return;
const exists = folder.children.some((c) => c.itemKey === item.itemKey);
if (!exists) folder.children.push(JSON.parse(JSON.stringify(item)));
}
function mergeRegistry() {
const relativePath = 'src/prototypes/oneos-prototype-nav/prototype-registry.json';
const ours = readJsonFromGit(2, relativePath);
const theirs = readJsonFromGit(3, relativePath);
const merged = {
version: 1,
updatedAt: new Date().toISOString(),
prototypes: { ...theirs.prototypes },
recentUpdates: [],
};
for (const key of ['oneos-web-h2-station-weekly', 'oneos-web-h2-station-analysis']) {
if (ours.prototypes[key]) merged.prototypes[key] = ours.prototypes[key];
}
const seen = new Set();
const combinedRecent = [...(ours.recentUpdates || []), ...(theirs.recentUpdates || [])];
for (const entry of combinedRecent) {
const sig = `${entry.prototypeId}|${entry.version}|${entry.date}|${entry.time}`;
if (seen.has(sig)) continue;
seen.add(sig);
merged.recentUpdates.push(entry);
}
merged.recentUpdates = merged.recentUpdates.slice(0, 40);
writeJson(relativePath, merged);
return {
prototypeCount: Object.keys(merged.prototypes).length,
recentCount: merged.recentUpdates.length,
hasXll: Boolean(merged.prototypes['xll-miniapp']),
h2AnalysisVersion: merged.prototypes['oneos-web-h2-station-analysis']?.version,
};
}
function mergeSidebar() {
const relativePath = '.axhub/make/sidebar-tree.json';
const ours = readJsonFromGit(2, relativePath);
const theirs = readJsonFromGit(3, relativePath);
const merged = JSON.parse(JSON.stringify(theirs));
merged.updatedAt = new Date().toISOString();
const oursKeys = collectItemKeys(ours.prototypes);
const mergedKeys = collectItemKeys(merged.prototypes);
const localOnlyKeys = [
'prototypes/oneos-web-h2-station-weekly',
'prototypes/oneos-web-h2-station-analysis',
].filter((key) => oursKeys.has(key) && !mergedKeys.has(key));
const oneOsFolder = findFolder(merged.prototypes, 'OneOS');
const h2Folder = oneOsFolder ? findFolder(oneOsFolder.children, '加氢站管理') : findFolder(merged.prototypes, '加氢站管理');
if (h2Folder) {
for (const itemKey of localOnlyKeys) {
const source =
findItemByKey(ours.prototypes, itemKey) ||
({
'prototypes/oneos-web-h2-station-weekly': {
id: 'item:prototypes:oneos-web-h2-station-weekly',
kind: 'item',
title: '站点周报统计',
itemKey: 'prototypes/oneos-web-h2-station-weekly',
},
'prototypes/oneos-web-h2-station-analysis': {
id: 'item-prototypes-oneos-web-h2-station-analysis',
kind: 'item',
title: '加氢站分析',
itemKey: 'prototypes/oneos-web-h2-station-analysis',
},
}[itemKey]);
ensureItem(h2Folder, source);
}
}
writeJson(relativePath, merged);
return {
addedKeys: localOnlyKeys,
itemCount: collectItemKeys(merged.prototypes).size,
};
}
const registry = mergeRegistry();
const sidebar = mergeSidebar();
console.log('[merge] prototype-registry.json', registry);
console.log('[merge] sidebar-tree.json', sidebar);

View File

@@ -0,0 +1,62 @@
/**
* 将原型 index.tsx 中的 AnnotationViewer 迁移为 PrototypeAnnotationHost。
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, '..');
const prototypesRoot = path.join(projectRoot, 'src/prototypes');
const hostImport = "import { PrototypeAnnotationHost } from '../../common/prototype-annotation-host';";
function listIndexFiles() {
return fs.readdirSync(prototypesRoot, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => path.join(prototypesRoot, entry.name, 'index.tsx'))
.filter((filePath) => fs.existsSync(filePath))
.filter((filePath) => fs.readFileSync(filePath, 'utf8').includes('AnnotationViewer'));
}
function migrateFile(filePath) {
let content = fs.readFileSync(filePath, 'utf8');
if (!content.includes('AnnotationViewer')) return false;
if (content.includes('PrototypeAnnotationHost')) {
console.log(`[skip] ${path.relative(projectRoot, filePath)}`);
return false;
}
content = content.replace(
/import\s*\{([^}]*)\}\s*from\s*'@axhub\/annotation';/u,
(match, importsBlock) => {
const cleaned = importsBlock
.split(',')
.map((item) => item.trim())
.filter((item) => item && item !== 'AnnotationViewer')
.join(',\n ');
return `import {\n ${cleaned}\n} from '@axhub/annotation';`;
},
);
if (!content.includes(hostImport)) {
content = content.replace(
/\} from '@axhub\/annotation';/u,
`} from '@axhub/annotation';\n${hostImport}`,
);
}
content = content
.replace(/<AnnotationViewer\b/gu, '<PrototypeAnnotationHost')
.replace(/<\/AnnotationViewer>/gu, '</PrototypeAnnotationHost>')
.replace(/React\.createElement\(AnnotationViewer,/gu, 'React.createElement(PrototypeAnnotationHost,');
fs.writeFileSync(filePath, content, 'utf8');
console.log(`[migrate] ${path.relative(projectRoot, filePath)}`);
return true;
}
let changed = 0;
for (const filePath of listIndexFiles()) {
if (migrateFile(filePath)) changed += 1;
}
console.log(`完成:迁移 ${changed} 个 index.tsx`);

View File

@@ -0,0 +1,193 @@
#!/usr/bin/env node
/**
* 打包 OneOS V2 设计规范「其他前端外发包」为 zip。
*
* 用法(在仓库根目录):
* node scripts/pack-oneos-v2-design-handoff.mjs
* node scripts/pack-oneos-v2-design-handoff.mjs --out ~/Desktop
*
* 产出目录默认dist/oneos-v2-design-handoff/
* zip 文件名oneos-v2-design-handoff-v{版本}-{日期}.zip
*
* 不含 UIComponents.tsx / prototypes另一套前端不依赖本仓库 React 组件)。
*/
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const DS = path.join(ROOT, 'src/resources/design-system');
function parseArgs(argv) {
const out = { outDir: path.join(ROOT, 'dist') };
for (let i = 2; i < argv.length; i += 1) {
if (argv[i] === '--out' && argv[i + 1]) {
out.outDir = path.resolve(argv[++i]);
} else if (argv[i] === '--help' || argv[i] === '-h') {
out.help = true;
}
}
return out;
}
function readDesignVersion() {
const designPath = path.join(DS, 'DESIGN.md');
const text = fs.readFileSync(designPath, 'utf8');
const m = text.match(/\|\s*文档版本\s*\|\s*\*\*([^*]+)\*\*/);
if (m) {
const raw = m[1].trim();
const ver = raw.match(/v?\d+(?:\.\d+)*/i);
return ver ? ver[0].replace(/^v/i, '') : 'unknown';
}
return 'unknown';
}
function dateStamp() {
const d = new Date();
const y = d.getFullYear();
const mo = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}${mo}${day}`;
}
/** @type {Array<{ from: string, to: string, optional?: boolean }>} */
const FILES = [
{ from: 'DESIGN.md', to: 'DESIGN.md' },
{ from: 'README.md', to: 'README.md' },
{ from: 'PRIORITY.md', to: 'PRIORITY.md' },
{ from: 'HANDOFF-其他前端.md', to: 'HANDOFF-其他前端.md' },
{ from: 'HANDOFF-Confluence摘要.md', to: 'HANDOFF-Confluence摘要.md' },
{ from: 'HANDOFF-Codex开发.md', to: 'HANDOFF-Codex开发.md' },
{ from: 'ai-prompt-template.md', to: 'ai-prompt-template.md' },
{ from: 'tokens.json', to: 'tokens.json' },
{ from: 'theme-variants.json', to: 'theme-variants.json', optional: true },
{ from: 'oneos-ds-tokens.css', to: 'oneos-ds-tokens.css' },
{ from: 'oneos-ds-filter-affordance.css', to: 'oneos-ds-filter-affordance.css' },
{ from: 'ruoyi-theme-preset.json', to: 'ruoyi-theme-preset.json' },
{ from: 'ruoyi-oneos-v2-theme.css', to: 'ruoyi-oneos-v2-theme.css' },
];
function copyFile(src, dest, optional = false) {
if (!fs.existsSync(src)) {
if (optional) {
console.warn(`[skip optional] ${src}`);
return false;
}
throw new Error(`缺少文件: ${src}`);
}
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.copyFileSync(src, dest);
return true;
}
function copyDirRecursive(srcDir, destDir) {
if (!fs.existsSync(srcDir)) {
throw new Error(`缺少目录: ${srcDir}`);
}
fs.mkdirSync(destDir, { recursive: true });
for (const name of fs.readdirSync(srcDir)) {
const from = path.join(srcDir, name);
const to = path.join(destDir, name);
const st = fs.statSync(from);
if (st.isDirectory()) {
copyDirRecursive(from, to);
} else if (st.isFile() && name.endsWith('.md')) {
fs.copyFileSync(from, to);
}
}
}
function writePackageReadme(packRoot, version) {
const body = `# OneOS V2 设计规范 · 其他前端外发包
| 项 | 说明 |
|---|---|
| DESIGN 版本 | v${version} |
| 打包日期 | ${dateStamp()} |
| 展厅预览 | https://prototype.lnoneos.com/oneos-v2/index.html |
| 台账母版 | https://prototype.lnoneos.com/lease-contract-management/index.html |
## 先读哪份
1. **一页摘要(可贴 Confluence**\`HANDOFF-Confluence摘要.md\`
2. **完整接入步骤(其他前端)**\`HANDOFF-其他前端.md\`
3. **研发 Codex 接入**\`HANDOFF-Codex开发.md\`
4. **总规范**\`DESIGN.md\`
5. **给 AI**\`ai-prompt-template.md\`(配合 HANDOFF §4 改路径)
## 建议落盘
解压后复制到对方仓库:
\`\`\`text
docs/oneos-v2/
\`\`\`
## 本包不含
- \`UIComponents.tsx\` 等 React 组件实现(请按规范自研等价控件)
- Axhub 业务原型源码
维护方OneOS 产品 / 设计规范。
`;
fs.writeFileSync(path.join(packRoot, '00-请先阅读.md'), body, 'utf8');
}
function zipFolder(folderPath, zipPath) {
const parent = path.dirname(folderPath);
const base = path.basename(folderPath);
// Prefer system zip for broad compatibility on macOS
const result = spawnSync('zip', ['-r', '-q', zipPath, base], {
cwd: parent,
encoding: 'utf8',
});
if (result.status !== 0) {
throw new Error(`zip 失败: ${result.stderr || result.stdout || result.error}`);
}
}
function main() {
const args = parseArgs(process.argv);
if (args.help) {
console.log(`Usage: node scripts/pack-oneos-v2-design-handoff.mjs [--out <dir>]`);
process.exit(0);
}
if (!fs.existsSync(DS)) {
console.error(`找不到设计规范目录: ${DS}`);
process.exit(1);
}
const version = readDesignVersion();
const stamp = dateStamp();
const packName = `oneos-v2-design-handoff-v${version}-${stamp}`;
const stagingParent = path.join(args.outDir, 'oneos-v2-design-handoff');
const packRoot = path.join(stagingParent, packName);
const zipPath = path.join(args.outDir, `${packName}.zip`);
fs.rmSync(packRoot, { recursive: true, force: true });
fs.mkdirSync(packRoot, { recursive: true });
for (const item of FILES) {
copyFile(path.join(DS, item.from), path.join(packRoot, item.to), item.optional);
}
copyDirRecursive(path.join(DS, 'chapters'), path.join(packRoot, 'chapters'));
writePackageReadme(packRoot, version);
fs.mkdirSync(path.dirname(zipPath), { recursive: true });
if (fs.existsSync(zipPath)) fs.unlinkSync(zipPath);
zipFolder(packRoot, zipPath);
const sizeKb = Math.round(fs.statSync(zipPath).size / 1024);
console.log('打包完成');
console.log(` 版本: v${version}`);
console.log(` 目录: ${packRoot}`);
console.log(` ZIP : ${zipPath} (${sizeKb} KB)`);
console.log('');
console.log('下一步:把 ZIP 发给对方,并附上展厅链接 https://prototype.lnoneos.com/oneos-v2/index.html');
}
main();

View File

@@ -0,0 +1,349 @@
#!/usr/bin/env node
/**
* 将项目原型页发布到 S3阿里云 OSS
* 流程Make Admin export-html 构建静态包 → 按页面独立前缀上传到 OSS。
*
* 用法:
* node scripts/publish-all-to-s3.mjs
* node scripts/publish-all-to-s3.mjs --dry-run
* node scripts/publish-all-to-s3.mjs --group prototypes
* node scripts/publish-all-to-s3.mjs --ids vehicle-management,oneos-prototype-nav
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import extract from 'extract-zip';
import { readServerInfo } from './utils/serverInfo.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, '..');
const CONFIG_PATH = path.join(projectRoot, '.axhub/make/axhub.config.json');
function parseArgs(argv) {
const args = {
// 默认 oneos-v2Make 多工程时务必传 --project-id避免发到旧仓导致 NoSuchKey
projectId: 'oneos-v2',
adminOrigin: '',
dryRun: false,
group: 'all',
ids: [],
includeSource: true,
};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--project-id' && argv[i + 1]) args.projectId = argv[++i].trim();
else if (arg === '--admin-origin' && argv[i + 1]) args.adminOrigin = argv[++i].trim();
else if (arg === '--group' && argv[i + 1]) args.group = argv[++i].trim();
else if (arg === '--ids' && argv[i + 1]) {
args.ids = argv[++i].split(',').map((id) => id.trim()).filter(Boolean);
}
else if (arg === '--dry-run') args.dryRun = true;
else if (arg === '--no-source') args.includeSource = false;
}
return args;
}
function resolveAdminOrigin(explicit) {
if (explicit) return explicit.replace(/\/+$/u, '');
const info = readServerInfo(projectRoot, 'admin');
if (info?.origin) return info.origin.replace(/\/+$/u, '');
return 'http://localhost:53817';
}
function readS3Config() {
const raw = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
const s3 = raw?.cloudPublishing?.s3;
if (!s3?.accessKeyId || !s3?.secretAccessKey || !s3?.bucket) {
throw new Error('请在 .axhub/make/axhub.config.json 中配置 cloudPublishing.s3');
}
return {
...s3,
pathAliases: s3.pathAliases && typeof s3.pathAliases === 'object' ? s3.pathAliases : {},
includeSource: raw?.cloudPublishing?.publishSettings?.includeSource !== false,
};
}
function toPublishPath(resource) {
const raw = resource.filePath || resource.sourcePath || '';
return String(raw).replace(/\\/gu, '/').replace(/\/index\.tsx?$/iu, '');
}
function resolveObjectPrefix(entry, pathAliases = {}) {
const alias = pathAliases[entry.id];
if (alias) return String(alias).replace(/^\/+|\/+$/gu, '');
const normalized = entry.path.replace(/^\/+|\/+$/gu, '');
const prototypeMatch = normalized.match(/^(?:src\/)?prototypes\/([^/]+)$/u);
// 与 Make 客户端「发布到对象存储」一致:/{prototype-id}/index.html不加 prototypes/ 前缀)
if (prototypeMatch?.[1]) return prototypeMatch[1];
const themeMatch = normalized.match(/^(?:src\/)?themes\/(.+)$/u);
if (themeMatch?.[1]) return `themes/${themeMatch[1]}`;
return entry.id;
}
/**
* OSS 命名空间:
* - 优先显式 s3.prefix不推荐Make 客户端会把它当成唯一目录,丢掉原型 id
* - 否则取 baseUrl 路径段推荐prefix 留空 + baseUrl=https://prototype.lnoneos.com/v2 → 命名空间 v2
*/
function resolveS3Namespace(s3Config) {
const explicit = String(s3Config?.prefix || '').replace(/^\/+|\/+$/gu, '');
if (explicit) return explicit;
try {
return new URL(String(s3Config?.baseUrl || '')).pathname.replace(/^\/+|\/+$/gu, '');
} catch {
return '';
}
}
/**
* 公网链接规则:
* - OSS 实际上传前缀 = `{namespace}/{prototypeId}`(见 publishEntry
* - baseUrl 若已带命名空间路径(.../v2公网只拼 prototypeId避免 /v2/v2/
* - baseUrl 为域名根时,公网拼完整 objectPrefix
*/
function resolvePublicUrl(baseUrl, objectPrefix, entryPrefix = '', s3Prefix = '') {
const base = String(baseUrl || '').replace(/\/+$/u, '');
const ossPrefix = String(s3Prefix || '').replace(/^\/+|\/+$/gu, '');
const protoPrefix = String(entryPrefix || '').replace(/^\/+|\/+$/gu, '');
const fullPrefix = String(objectPrefix || '').replace(/^\/+|\/+$/gu, '');
let pathname = '';
try {
pathname = new URL(base).pathname.replace(/\/+$/u, '');
} catch {
pathname = '';
}
const baseAlreadyHasOssPrefix = Boolean(
ossPrefix && (pathname === `/${ossPrefix}` || pathname.endsWith(`/${ossPrefix}`)),
);
const publicPrefix = baseAlreadyHasOssPrefix
? (protoPrefix || fullPrefix.replace(new RegExp(`^${ossPrefix}/`), ''))
: fullPrefix;
const key = `${publicPrefix.replace(/^\/+|\/+$/gu, '')}/index.html`.replace(/^\/+/u, '');
return `${base}/${key.split('/').map(encodeURIComponent).join('/')}`;
}
function guessContentType(filePath) {
const ext = path.extname(filePath).toLowerCase();
const map = {
'.html': 'text/html; charset=utf-8',
'.js': 'application/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.webp': 'image/webp',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.svg': 'image/svg+xml',
'.woff2': 'font/woff2',
'.woff': 'font/woff',
'.txt': 'text/plain; charset=utf-8',
};
return map[ext] || 'application/octet-stream';
}
async function collectPublishEntries(adminOrigin, projectId, groupFilter, pathAliases = {}) {
const res = await fetch(`${adminOrigin}/api/projects/${encodeURIComponent(projectId)}/resources`);
if (!res.ok) {
throw new Error(`GET /api/projects/${projectId}/resources failed: ${res.status}`);
}
const data = await res.json();
const groups = groupFilter === 'all' ? ['prototypes', 'themes'] : [groupFilter];
const entries = [];
for (const group of groups) {
for (const item of data.resources?.[group] || []) {
const publishPath = toPublishPath(item);
if (!publishPath) continue;
entries.push({
group,
id: item.id,
title: item.title || item.id,
path: publishPath,
prefix: resolveObjectPrefix({ path: publishPath, id: item.id }, pathAliases),
});
}
}
return entries;
}
async function exportHtmlZip(adminOrigin, projectId, entry, includeSource) {
const params = new URLSearchParams({
path: entry.path,
projectId,
});
if (includeSource) params.set('includeSource', 'true');
const res = await fetch(`${adminOrigin}/api/export-html?${params.toString()}`);
if (!res.ok) {
const text = await res.text().catch(() => '');
let message = text;
try {
message = JSON.parse(text).error || text;
} catch {
// keep raw text
}
throw new Error(message || `export-html failed (${res.status})`);
}
const buffer = Buffer.from(await res.arrayBuffer());
if (buffer.length < 4 || buffer[0] !== 0x50 || buffer[1] !== 0x4b) {
throw new Error('export-html 未返回有效 ZIP');
}
return buffer;
}
function resolveS3Endpoint(s3Config) {
const bucket = String(s3Config.bucket || '').trim();
const endpoint = String(s3Config.endpoint || '').trim().replace(/\/+$/u, '');
const region = String(s3Config.region || 'cn-hangzhou').trim();
if (endpoint) {
// 配置里可能是 bucket 级域名model-lnoneos.oss-...AWS SDK 只需区域 endpoint。
const bucketHostPrefix = bucket ? `${bucket}.` : '';
if (bucketHostPrefix && endpoint.includes(bucketHostPrefix)) {
return endpoint.replace(bucketHostPrefix, '');
}
return endpoint;
}
return `https://oss-${region}.aliyuncs.com`;
}
function createS3Client(s3Config) {
return new S3Client({
region: s3Config.region || 'cn-hangzhou',
endpoint: resolveS3Endpoint(s3Config),
forcePathStyle: false,
credentials: {
accessKeyId: s3Config.accessKeyId,
secretAccessKey: s3Config.secretAccessKey,
},
});
}
async function uploadDirectory(client, bucket, prefix, dir) {
const uploaded = [];
const walk = async (currentDir, relative = '') => {
for (const name of fs.readdirSync(currentDir)) {
const abs = path.join(currentDir, name);
const rel = relative ? `${relative}/${name}` : name;
if (fs.statSync(abs).isDirectory()) {
await walk(abs, rel);
continue;
}
const key = [prefix.replace(/^\/+|\/+$/gu, ''), rel].filter(Boolean).join('/');
await client.send(new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: fs.readFileSync(abs),
ContentType: guessContentType(abs),
}));
uploaded.push(key);
}
};
await walk(dir);
return uploaded;
}
async function publishEntry(client, s3Config, adminOrigin, projectId, entry, includeSource) {
const zipBuffer = await exportHtmlZip(adminOrigin, projectId, entry, includeSource);
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'axhub-publish-'));
const zipPath = path.join(tempDir, 'bundle.zip');
const extractDir = path.join(tempDir, 'site');
fs.writeFileSync(zipPath, zipBuffer);
fs.mkdirSync(extractDir, { recursive: true });
await extract(zipPath, { dir: extractDir });
const namespace = resolveS3Namespace(s3Config);
const objectPrefix = namespace ? `${namespace}/${entry.prefix}` : entry.prefix;
const keys = await uploadDirectory(client, s3Config.bucket, objectPrefix, extractDir);
fs.rmSync(tempDir, { recursive: true, force: true });
return {
url: resolvePublicUrl(s3Config.baseUrl, objectPrefix, entry.prefix, namespace),
fileCount: keys.length,
keys,
};
}
async function main() {
const args = parseArgs(process.argv.slice(2));
const adminOrigin = resolveAdminOrigin(args.adminOrigin);
const s3Config = readS3Config();
const includeSource = args.includeSource && s3Config.includeSource;
let entries = await collectPublishEntries(adminOrigin, args.projectId, args.group, s3Config.pathAliases);
if (args.ids.length > 0) {
const idSet = new Set(args.ids);
entries = entries.filter((entry) => idSet.has(entry.id));
const missing = args.ids.filter((id) => !entries.some((entry) => entry.id === id));
if (missing.length > 0) {
throw new Error(`未找到原型: ${missing.join(', ')}`);
}
}
const namespace = resolveS3Namespace(s3Config);
console.log(`Admin: ${adminOrigin}`);
console.log(`Bucket: ${s3Config.bucket}`);
console.log(`Base URL: ${s3Config.baseUrl}`);
console.log(`OSS namespace: ${namespace || '(bucket root)'}`);
console.log(`Pages to publish: ${entries.length}`);
if (args.dryRun) {
for (const entry of entries) {
const objectPrefix = namespace ? `${namespace}/${entry.prefix}` : entry.prefix;
const url = resolvePublicUrl(s3Config.baseUrl, objectPrefix, entry.prefix, namespace);
console.log(`[dry-run] ${entry.group}/${entry.id} -> ${objectPrefix}/index.html`);
console.log(` ${url}`);
}
return;
}
const client = createS3Client(s3Config);
const results = [];
let failed = 0;
for (let i = 0; i < entries.length; i += 1) {
const entry = entries[i];
const label = `[${i + 1}/${entries.length}] ${entry.group}/${entry.id}`;
process.stdout.write(`${label} ... `);
try {
const result = await publishEntry(client, s3Config, adminOrigin, args.projectId, entry, includeSource);
console.log(`OK ${result.url} (${result.fileCount} files)`);
results.push({ ...entry, status: 'success', ...result });
} catch (error) {
failed += 1;
const message = error instanceof Error ? error.message : String(error);
console.log(`FAIL ${message}`);
results.push({ ...entry, status: 'failed', error: message });
}
}
const reportPath = path.join(
projectRoot,
'.axhub/make/exports',
`cloud.publish.s3-all-${new Date().toISOString().replace(/[:.]/gu, '-')}.json`,
);
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
fs.writeFileSync(reportPath, `${JSON.stringify({
schemaVersion: 2,
projectId: args.projectId,
target: 's3',
method: 'export-html+s3-upload',
total: entries.length,
success: entries.length - failed,
failed,
completedAt: new Date().toISOString(),
results: results.map(({ keys, ...rest }) => ({
...rest,
fileCount: rest.fileCount ?? keys?.length ?? 0,
})),
}, null, 2)}\n`, 'utf8');
console.log(`\nDone: ${entries.length - failed}/${entries.length} succeeded`);
if (failed > 0) process.exitCode = 1;
console.log(`Report: ${reportPath}`);
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
});

View File

@@ -0,0 +1,112 @@
#!/usr/bin/env node
import fs from 'fs';
import path from 'path';
const ROOT = path.resolve('src/prototypes');
function walk(dir, out = []) {
for (const name of fs.readdirSync(dir)) {
const full = path.join(dir, name);
if (fs.statSync(full).isDirectory()) {
if (name === 'node_modules' || name === '.spec') continue;
walk(full, out);
} else if (/\.(jsx|tsx)$/.test(name)) out.push(full);
}
return out;
}
function cleanup(source) {
let s = source;
// Fix corrupted fragment from partial breadcrumb removal (37-新增后装设备)
s = s.replace(
/\{ style: styles\.page \},\s*\{ e\.preventDefault\(\); \} \}, '运维管理'\),[\s\S]*?React\.createElement\('span', \{ style: styles\.breadcrumbCurrent \}, '[^']+'\)\s*\),/g,
'{ style: styles.page },'
);
// Top bar: breadcrumb spans + req button -> req button only
s = s.replace(
/React\.createElement\('div', \{ style: \{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 \} \},\s*React\.createElement\('span'[\s\S]*?\),\s*(React\.createElement\(Button, \{ type: 'link'[\s\S]*?'查看需求说明'\)\s*)\),/g,
`React.createElement('div', { style: { display: 'flex', justifyContent: 'flex-end', alignItems: 'center', marginBottom: 16 } }, $1),`
);
// Lease contract rows with broken partial breadcrumb
s = s.replace(
/React\.createElement\('div', \{ style: \{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 \} \},\s*React\.createElement\('span'[\s\S]*?\),\s*(React\.createElement\('(?:button|span)'[\s\S]*?'查看需求说明'\)[^)]*\))/g,
`React.createElement('div', { style: { display: 'flex', justifyContent: 'flex-end', alignItems: 'center', marginBottom: 16 } }, $1)`
);
// Lease contract row - only breadcrumb spans ending with )), no req
s = s.replace(
/React\.createElement\('div', \{ style: \{ marginBottom: 16 \} \},\s*React\.createElement\('span'[\s\S]*?\)\),/g,
''
);
// 后装设备 list page partial breadcrumb block inside createElement
s = s.replace(
/React\.createElement\('div', \{ style: styles\.breadcrumbLeft \},[\s\S]*?React\.createElement\('span', \{ style: styles\.breadcrumbCurrent \}, '[^']+'\)\s*\),/g,
''
);
// Orphan breadcrumb link lines after broken removal
s = s.replace(
/\n\t\t\tReact\.createElement\('a', \{ href: '#', style: styles\.breadcrumbLink[\s\S]*?breadcrumbCurrent \}, '[^']+'\)\n\t\t\),/g,
''
);
// Remove breadcrumbItems variable blocks
s = s.replace(/\n\tvar breadcrumbItems = \[[\s\S]*?\];\n/g, '\n');
s = s.replace(/\n\tvar breadcrumbItems = \[[\s\S]*?\];\n/g, '\n');
s = s.replace(/\n\t\tbreadcrumbItems\.push\([^)]+\);\n/g, '');
// 备车/交车任务 inline breadcrumb rows (partial)
s = s.replace(
/React\.createElement\('div', \{ style: styles\.breadcrumb[^}]*\},[\s\S]*?\),?\n?/g,
''
);
s = s.replace(
/React\.createElement\('span', \{ key: '[^']+', style: styles\.breadcrumbSep \}, ' \/ '\),?\n?\s*/g,
''
);
// Arco/fault pages: remove Breadcrumb from Layout header if any remain
s = s.replace(/,\s*breadcrumb:\s*React\.createElement\(Breadcrumb[\s\S]*?\)/g, '');
// business pages partial breadcrumb in flex div
s = s.replace(
/React\.createElement\('div', \{ style: \{ display: 'flex', alignItems: 'center', marginBottom: 16 \} \},[\s\S]*?styles\.breadcrumbSep[\s\S]*?\),/g,
''
);
// Fix double spaces in lease contract lines
s = s.replace(/marginBottom: 16 \} \},\s{2}React/g, 'marginBottom: 16 } }, React');
// 27-交车管理: broken topbar after breadcrumb removal
s = s.replace(
/React\.createElement\('div', \{ style: \{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 \} \},\s*React\.createElement\(Button,/g,
`React.createElement('div', { style: { display: 'flex', justifyContent: 'flex-end', alignItems: 'center', marginBottom: 16 } }, React.createElement(Button,`
);
return s;
}
function fixCollectPageIndent(source) {
return source.replace(
/(\}, '返回'\),\n)\s+React\.createElement\(Button,/g,
`$1\t\t\tReact.createElement(Button,`
);
}
const files = walk(ROOT);
let changed = 0;
for (const file of files) {
let source = fs.readFileSync(file, 'utf8');
if (!/breadcrumb|Breadcrumb|面包屑/.test(source)) continue;
const next = fixCollectPageIndent(cleanup(source));
if (next !== source) {
fs.writeFileSync(file, next);
changed++;
console.log('fixed:', path.relative(process.cwd(), file));
}
}
console.log(`Pass 2 done. ${changed} file(s).`);

View File

@@ -0,0 +1,62 @@
#!/usr/bin/env node
import fs from 'fs';
import path from 'path';
const ROOT = path.resolve('src/prototypes');
function walk(dir, out = []) {
for (const name of fs.readdirSync(dir)) {
const full = path.join(dir, name);
if (fs.statSync(full).isDirectory()) {
if (name === 'node_modules' || name === '.spec') continue;
walk(full, out);
} else if (/\.(jsx|tsx)$/.test(name)) out.push(full);
}
return out;
}
function cleanup(source) {
let s = source;
// Lease contract topbar: remove span trail, keep req action
s = s.replace(
/React\.createElement\('div', \{ style: \{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', marginBottom: 16 \} \}, React\.createElement\('span'[\s\S]*?\)\),\s*(React\.createElement\('(?:button|span)'[\s\S]*?'查看需求说明'\)[^)]*\)\),/g,
`React.createElement('div', { style: { display: 'flex', justifyContent: 'flex-end', alignItems: 'center', marginBottom: 16 } }, $1),`
);
// Topbar only breadcrumb spans (no req button)
s = s.replace(
/React\.createElement\('div', \{ style: \{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', marginBottom: 16 \} \}, React\.createElement\('span'[\s\S]*?\)\),/g,
''
);
// Unused breadcrumb vars
s = s.replace(/\n\tvar breadcrumbItems = \[[\s\S]*?\];\n/g, '\n');
s = s.replace(/\n\t\tvar breadcrumbItems = \[[\s\S]*?\];\n/g, '\n');
s = s.replace(/\n\tvar breadcrumbNodes = \[[\s\S]*?\];\n/g, '\n');
// Broken 后装设备 page wrapper
s = s.replace(
/(\{ style: styles\.page \},\n)\s*React\.createElement\('a', \{ href: '#', style: styles\.requirementLink[\s\S]*?'查看需求说明'\)\n\t\t\),/g,
`$1\t\tReact.createElement('div', { style: { display: 'flex', justifyContent: 'flex-end', marginBottom: 16 } },\n\t\t\tReact.createElement('a', { href: '#', style: styles.requirementLink, onClick: function (e) { e.preventDefault(); setShowRequirementModal(true); } }, '查看需求说明')\n\t\t),\n`
);
// Empty if blocks left from breadcrumb removal in 业务台账
s = s.replace(/\n\tif \(view === 'sales' \|\| view === 'project'\) \{\s*\}\n\tif \(view === 'project'\) \{\s*\}\n/g, '\n');
return s;
}
const files = walk(ROOT);
let changed = 0;
for (const file of files) {
let source = fs.readFileSync(file, 'utf8');
if (!/breadcrumb|Breadcrumb|面包屑/.test(source)) continue;
const next = cleanup(source);
if (next !== source) {
fs.writeFileSync(file, next);
changed++;
console.log('fixed:', path.relative(process.cwd(), file));
}
}
console.log(`Pass 3 done. ${changed} file(s).`);

View File

@@ -0,0 +1,213 @@
#!/usr/bin/env node
/**
* Remove breadcrumb UI from prototype pages under src/prototypes.
* Keeps sibling actions such as「查看需求说明」when present in breadcrumbRight.
*/
import fs from 'fs';
import path from 'path';
const ROOT = path.resolve('src/prototypes');
function walk(dir, out = []) {
for (const name of fs.readdirSync(dir)) {
const full = path.join(dir, name);
const stat = fs.statSync(full);
if (stat.isDirectory()) {
if (name === 'node_modules' || name === '.spec') continue;
walk(full, out);
} else if (/\.(jsx|tsx)$/.test(name)) {
out.push(full);
}
}
return out;
}
function removeBalancedFrom(source, openIndex) {
let i = openIndex;
if (source[i] !== '(') return null;
let depth = 0;
let inStr = null;
let escape = false;
for (; i < source.length; i++) {
const ch = source[i];
if (inStr) {
if (escape) {
escape = false;
continue;
}
if (ch === '\\') {
escape = true;
continue;
}
if (ch === inStr) inStr = null;
continue;
}
if (ch === "'" || ch === '"' || ch === '`') {
inStr = ch;
continue;
}
if (ch === '(') depth++;
else if (ch === ')') {
depth--;
if (depth === 0) return source.slice(openIndex, i + 1);
}
}
return null;
}
function removeReactBreadcrumbCalls(source) {
const token = 'React.createElement(Breadcrumb,';
let result = source;
let idx = 0;
while ((idx = result.indexOf(token, idx)) !== -1) {
const open = result.indexOf('(', idx);
const chunk = removeBalancedFrom(result, open);
if (!chunk) break;
let start = idx;
let end = open + chunk.length;
while (start > 0 && /[\t ]/.test(result[start - 1])) start--;
if (result[start - 1] === ',') start--;
if (result[start - 1] === '\n' && result[start - 2] === ',') start--;
while (end < result.length && /[\t ,]/.test(result[end])) end++;
if (result[end] === '\n') end++;
result = result.slice(0, start) + result.slice(end);
idx = start;
}
return result;
}
function removeBreadcrumbImports(source) {
return source
.replace(/^\s*var Breadcrumb = antd\.Breadcrumb;\n/gm, '')
.replace(/^\s*const Breadcrumb = antd\.Breadcrumb;\n/gm, '')
.replace(/^\s*import\s+\{[^}]*\bBreadcrumb\b[^}]*\}\s+from\s+['"][^'"]+['"];\n/gm, (line) =>
line.replace(/\bBreadcrumb,?\s*/g, '').replace(/,\s*,/g, ',').replace(/\{\s*,/g, '{').replace(/,\s*\}/g, '}').replace(/import\s+\{\s*\}\s+from[^;]+;\n/g, '')
);
}
function removeNavBreadcrumbs(source) {
return source.replace(
/\n?\s*<nav[^>]*breadcrumb[^>]*>[\s\S]*?<\/nav>\n?/gi,
'\n'
);
}
function replaceJsxBreadcrumbBar(source) {
// JSX: breadcrumb row with optional right actions
return source.replace(
/\{\/\*[\s*]*面包屑[\s*]*\*\/\}\s*\n?\s*<div style=\{styles\.breadcrumb\}>[\s\S]*?<div style=\{styles\.breadcrumbRight\}>([\s\S]*?)<\/div>\s*<\/div>/g,
(_, right) => `\n\t\t\t<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 16 }}>${right.trim()}</div>`
);
}
function replaceCreateElementBreadcrumbBar(source) {
// createElement breadcrumb with Left + Right — keep Right only
const pattern = /React\.createElement\(\s*'div',\s*\{ style: styles\.breadcrumb \},[\s\S]*?React\.createElement\(\s*'div',\s*\{ style: styles\.breadcrumbRight \},([\s\S]*?)\)\s*\)/g;
return source.replace(
pattern,
(_, right) =>
`React.createElement('div', { style: { display: 'flex', justifyContent: 'flex-end', marginBottom: 16 } }, ${right.trim()})`
);
}
function replaceSimpleCreateElementBreadcrumb(source) {
// div with only styles.breadcrumb path segments (no Right sibling)
return source.replace(
/React\.createElement\(\s*'div',\s*\{ style: styles\.breadcrumb[^}]*\},[\s\S]*?\),?\n?/g,
''
);
}
function replaceLeaseContractBreadcrumbRow(source) {
// Long inline breadcrumb + req button row
return source.replace(
/React\.createElement\('div',\s*\{ style: \{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 \} \},\s*React\.createElement\('div',\s*\{ style: styles\.breadcrumb[^}]+\}[\s\S]*?'查看需求说明'\)\),/g,
(match) => {
const req = match.match(/React\.createElement\('button'[\s\S]*?'查看需求说明'\)/);
if (!req) return '';
return `React.createElement('div', { style: { display: 'flex', justifyContent: 'flex-end', alignItems: 'center', marginBottom: 16 } }, ${req[0]}),`;
}
);
}
function removeEmptyBreadcrumbWrappers(source) {
// div wrapper that only wrapped breadcrumb (now empty)
return source
.replace(
/React\.createElement\('div',\s*\{ style: \{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 \} \},\s*\),?\n?/g,
''
)
.replace(
/React\.createElement\('div',\s*\{ style: \{ marginBottom: 16 \} \},\s*\),?\n?/g,
''
)
.replace(
/React\.createElement\('div',\s*\{ className: 'vpr-collect-topbar__main' \},\s*\),?\n?/g,
''
);
}
function transformFile(filePath) {
let source = fs.readFileSync(filePath, 'utf8');
if (!/breadcrumb|Breadcrumb|面包屑/.test(source)) return false;
const original = source;
source = removeReactBreadcrumbCalls(source);
source = removeBreadcrumbImports(source);
source = removeNavBreadcrumbs(source);
source = replaceJsxBreadcrumbBar(source);
source = replaceCreateElementBreadcrumbBar(source);
source = replaceSimpleCreateElementBreadcrumb(source);
source = replaceLeaseContractBreadcrumbRow(source);
source = removeEmptyBreadcrumbWrappers(source);
// header with only req link after breadcrumb removed
source = source.replace(
/React\.createElement\(\s*'header',\s*\{ className: 'vr-page-header' \},\s*React\.createElement\(\s*Button,/g,
`React.createElement('header', { className: 'vr-page-header', style: { display: 'flex', justifyContent: 'flex-end', marginBottom: 16 } }, React.createElement(Button,`
);
// flex topbar: space-between -> flex-end when breadcrumb was first child
source = source.replace(
/justifyContent: 'space-between', marginBottom: 16 \} \},\s*React\.createElement\(Button, \{ type: 'link'/g,
`justifyContent: 'flex-end', marginBottom: 16 } }, React.createElement(Button, { type: 'link'`
);
if (source !== original) {
fs.writeFileSync(filePath, source);
return true;
}
return false;
}
const files = walk(ROOT);
let changed = 0;
for (const file of files) {
if (transformFile(file)) {
changed++;
console.log('updated:', path.relative(process.cwd(), file));
}
}
// CSS tweak for collect topbar
const vprCss = path.join(ROOT, 'vehicle-pickup-receivable/styles/index.css');
if (fs.existsSync(vprCss)) {
let css = fs.readFileSync(vprCss, 'utf8');
const next = css
.replace('/* 办理页顶栏:返回 + 面包屑 */', '/* 办理页顶栏:返回 + 操作 */')
.replace(
/\.vm-page\.vpr-collect-page \.vpr-collect-topbar \{\n display: flex;\n align-items: center;\n gap: 16px;/,
`.vm-page.vpr-collect-page .vpr-collect-topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;`
)
.replace(/\n\.vm-page\.vpr-collect-page \.vpr-collect-topbar__main \{[^}]+\}\n?/g, '\n');
if (next !== css) {
fs.writeFileSync(vprCss, next);
console.log('updated:', path.relative(process.cwd(), vprCss));
}
}
console.log(`\nDone. ${changed} file(s) updated.`);

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,144 @@
#!/usr/bin/env node
/**
* 基于 Stripe Fintech 高端风格的 表单页 (故障处置) 与 工作台页 (不含版本日志) 浅/暗截图生成器
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(__dirname, '..');
const formOutDir = path.join(root, 'src/resources/design-system/fault-form-shots');
const workbenchOutDir = path.join(root, 'src/resources/design-system/workbench-shots');
async function loadPuppeteer() {
const require = createRequire(import.meta.url);
try {
return require('puppeteer');
} catch {
return require('puppeteer-core');
}
}
async function main() {
fs.mkdirSync(formOutDir, { recursive: true });
fs.mkdirSync(workbenchOutDir, { recursive: true });
const puppeteer = await loadPuppeteer();
const browser = await puppeteer.launch({
headless: true,
executablePath:
process.env.PUPPETEER_EXECUTABLE_PATH ||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
defaultViewport: { width: 1440, height: 1024, deviceScaleFactor: 2 },
args: ['--no-sandbox', '--disable-setuid-sandbox'],
});
const page = await browser.newPage();
page.setDefaultTimeout(60000);
const baseUrl = 'http://127.0.0.1:51720';
const modes = ['light', 'dark'];
// 1. 生成故障处置表单页截图 (Form)
for (const mode of modes) {
const demoUrl = `${baseUrl}/prototypes/oneos-prototype-demo/?oneosTheme=${mode}#proto=lease-contract-redesign`;
console.log(`正在渲染 表单页 (故障处置) (${mode})...`);
await page.goto(demoUrl, { waitUntil: 'networkidle2' });
await page.waitForSelector('.oneos-shell', { timeout: 30000 });
await page.waitForSelector('.oneos-shell-frame', { timeout: 30000 });
await page.evaluate((m) => {
document.documentElement.dataset.dsMode = m;
document.documentElement.dataset.oneosTheme = m;
const shell = document.querySelector('.oneos-shell');
if (shell) shell.dataset.oneosTheme = m;
}, mode);
const iframeElement = await page.$('.oneos-shell-frame');
if (iframeElement) {
const targetSrc = `${baseUrl}/prototypes/lease-contract-redesign/?concept=form&oneosTheme=${mode}`;
await page.evaluate((el, src) => {
el.src = src;
}, iframeElement, targetSrc);
}
await new Promise((r) => setTimeout(r, 1200));
const childFrame = page.frames().find(
(f) => f !== page.mainFrame() && f.url().includes('lease-contract-redesign'),
);
if (childFrame) {
await childFrame.evaluate((m) => {
document.documentElement.dataset.dsMode = m;
document.documentElement.dataset.oneosTheme = m;
}, mode);
}
await new Promise((r) => setTimeout(r, 400));
const fileName = `fault-form-${mode}.png`;
const outPath = path.join(formOutDir, fileName);
await page.screenshot({ path: outPath, type: 'png', fullPage: false });
console.log('Successfully written Form image:', fileName);
}
// 2. 生成现代工作台页截图 (Workbench - 不含版本更新日志)
for (const mode of modes) {
const demoUrl = `${baseUrl}/prototypes/oneos-prototype-demo/?oneosTheme=${mode}#proto=lease-contract-redesign`;
console.log(`正在渲染 工作台页 (无版本日志) (${mode})...`);
await page.goto(demoUrl, { waitUntil: 'networkidle2' });
await page.waitForSelector('.oneos-shell', { timeout: 30000 });
await page.waitForSelector('.oneos-shell-frame', { timeout: 30000 });
await page.evaluate((m) => {
document.documentElement.dataset.dsMode = m;
document.documentElement.dataset.oneosTheme = m;
const shell = document.querySelector('.oneos-shell');
if (shell) shell.dataset.oneosTheme = m;
}, mode);
const iframeElement = await page.$('.oneos-shell-frame');
if (iframeElement) {
const targetSrc = `${baseUrl}/prototypes/lease-contract-redesign/?concept=workbench&oneosTheme=${mode}`;
await page.evaluate((el, src) => {
el.src = src;
}, iframeElement, targetSrc);
}
await new Promise((r) => setTimeout(r, 1200));
const childFrame = page.frames().find(
(f) => f !== page.mainFrame() && f.url().includes('lease-contract-redesign'),
);
if (childFrame) {
await childFrame.evaluate((m) => {
document.documentElement.dataset.dsMode = m;
document.documentElement.dataset.oneosTheme = m;
}, mode);
}
await new Promise((r) => setTimeout(r, 400));
const fileName = `workbench-${mode}.png`;
const outPath = path.join(workbenchOutDir, fileName);
await page.screenshot({ path: outPath, type: 'png', fullPage: false });
console.log('Successfully written Workbench image:', fileName);
}
await browser.close();
console.log('表单页与工作台页截图已成功全部生成!');
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -0,0 +1,246 @@
#!/usr/bin/env node
/**
* 租赁合同表单页 + 工作台 · 5 主题 × 浅/暗 截图
*
* Usage:
* node scripts/shot-form-workbench-themes.mjs
* node scripts/shot-form-workbench-themes.mjs --base=http://127.0.0.1:51720
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
import { THEME_SHOTS, buildSkinCss } from './lease-contract-theme-skins.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(__dirname, '..');
const outRoot = path.join(root, 'src/resources/design-system/lease-contract-theme-shots');
const PAGES = [
{
key: 'form',
title: '租赁合同 · 新增表单',
path: '/prototypes/lease-contract-management/',
ready: '.vm-page.lc-page',
shot: '.vm-page.lc-create-page, .vm-page.lc-page',
async prepare(page) {
// 已在表单页则跳过
const onCreate = await page.$('.lc-create-page');
if (onCreate) return;
// 点工具栏「新增」
const clicked = await page.evaluate(() => {
const buttons = Array.from(document.querySelectorAll('button, a, .vm-btn'));
const btn = buttons.find((el) => (el.textContent || '').trim() === '新增');
if (btn) {
btn.click();
return true;
}
return false;
});
if (!clicked) throw new Error('未找到「新增」按钮');
await page.waitForSelector('.lc-create-page', { timeout: 30000 });
await new Promise((r) => setTimeout(r, 500));
},
},
{
key: 'workbench',
title: '工作台',
path: '/prototypes/oneos-web-workbench-new/',
ready: '.vm-page.wb-page',
shot: '.vm-page.wb-page',
/** 比稿截图不需要版本更新弹层遮挡首屏 */
async beforeGoto(page) {
await page.evaluateOnNewDocument(() => {
const KEY = 'oneos-wb-release-seen-v1';
const VERSION = '1.1.5';
const operators = [
'王冕', '周凯', '王磊', '刘洋', '黄倩', '孙敏', '陈静', '赵律师', '张明',
];
const map = {};
try {
const raw = window.localStorage.getItem(KEY);
if (raw) Object.assign(map, JSON.parse(raw) || {});
} catch { /* ignore */ }
for (const name of operators) map[name] = VERSION;
window.localStorage.setItem(KEY, JSON.stringify(map));
});
},
async prepare(page) {
// 若仍弹出(缓存/旧会话),点「知道了」或强制隐藏
await page.evaluate(async () => {
const confirm = document.querySelector('.wb-release-confirm');
if (confirm instanceof HTMLElement) confirm.click();
await new Promise((r) => setTimeout(r, 200));
let style = document.getElementById('ds-theme-shot-hide-release');
if (!style) {
style = document.createElement('style');
style.id = 'ds-theme-shot-hide-release';
document.head.appendChild(style);
}
style.textContent = `
[data-annotation-id="wb-release-notes"],
.wb-overlay--release,
.wb-modal--release {
display: none !important;
visibility: hidden !important;
pointer-events: none !important;
}
`;
});
await new Promise((r) => setTimeout(r, 150));
},
},
];
function parseArgs(argv) {
const args = { base: 'http://127.0.0.1:51720' };
for (const a of argv) {
if (a.startsWith('--base=')) args.base = a.slice('--base='.length);
}
return args;
}
async function loadPuppeteer() {
const require = createRequire(import.meta.url);
try {
return require('puppeteer');
} catch {
return require('puppeteer-core');
}
}
async function applySkin(page, theme, mode) {
const css = buildSkinCss(theme, mode);
await page.evaluate((cssText, modeName) => {
document.documentElement.dataset.dsMode = modeName;
let el = document.getElementById('ds-theme-shot-skin');
if (!el) {
el = document.createElement('style');
el.id = 'ds-theme-shot-skin';
document.head.appendChild(el);
}
el.textContent = cssText;
}, css, mode);
await new Promise((r) => setTimeout(r, 250));
}
async function hideAnnotation(page) {
await page.evaluate(() => {
let el = document.getElementById('ds-theme-shot-hide-ann');
if (!el) {
el = document.createElement('style');
el.id = 'ds-theme-shot-hide-ann';
document.head.appendChild(el);
}
el.textContent = `
.axhub-annotation-host, [data-axhub-annotation],
[class*="Annotation"], [class*="annotation-toolbar"] {
display: none !important; visibility: hidden !important;
}
`;
});
}
async function main() {
const args = parseArgs(process.argv.slice(2));
const base = args.base.replace(/\/$/, '');
const puppeteer = await loadPuppeteer();
const browser = await puppeteer.launch({
headless: true,
executablePath:
process.env.PUPPETEER_EXECUTABLE_PATH
|| '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
defaultViewport: { width: 1440, height: 1100, deviceScaleFactor: 2 },
args: ['--no-sandbox', '--disable-setuid-sandbox'],
});
const allResults = [];
for (const pageCfg of PAGES) {
const outDir = path.join(outRoot, pageCfg.key);
fs.mkdirSync(outDir, { recursive: true });
const page = await browser.newPage();
page.setDefaultTimeout(60000);
const url = `${base}${pageCfg.path}`;
console.log(`\n== ${pageCfg.title} ==\n打开 ${url}`);
if (typeof pageCfg.beforeGoto === 'function') {
await pageCfg.beforeGoto(page);
}
await page.goto(url, { waitUntil: 'networkidle2' });
await page.waitForSelector(pageCfg.ready, { timeout: 60000 });
await pageCfg.prepare(page);
await hideAnnotation(page);
for (const theme of THEME_SHOTS) {
for (const mode of ['light', 'dark']) {
await applySkin(page, theme, mode);
const file = `${theme.id}-${mode}.png`;
const outPath = path.join(outDir, file);
const handle = await page.$(pageCfg.shot);
if (handle) {
await handle.screenshot({ path: outPath, type: 'png' });
} else {
await page.screenshot({ path: outPath, type: 'png' });
}
console.log('wrote', pageCfg.key + '/' + file);
allResults.push({
page: pageCfg.key,
pageTitle: pageCfg.title,
file: `${pageCfg.key}/${file}`,
theme: theme.name,
mode,
category: theme.category,
});
}
}
await page.close();
const readme = `# ${pageCfg.title} · 主题比稿截图
生成时间:${new Date().toISOString().slice(0, 10)}
| 文件 | 主题 | 模式 |
|------|------|------|
${allResults
.filter((r) => r.page === pageCfg.key)
.map((r) => `| \`${path.basename(r.file)}\` | ${r.theme} | ${r.mode === 'light' ? '浅色' : '暗色'} |`)
.join('\n')}
`;
fs.writeFileSync(path.join(outDir, 'README.md'), readme);
}
// 更新总 README
const indexMd = `# 主题比稿截图总览
生成时间:${new Date().toISOString().slice(0, 10)}
## 页面
| 目录 | 页面 |
|------|------|
| \`.\`(根目录 png | 租赁合同 · 列表 |
| [\`form/\`](./form/) | 租赁合同 · 新增表单 |
| [\`workbench/\`](./workbench/) | 工作台 |
## 五套主题
| 编号 | 主题 | 气质 |
|------|------|------|
| A | Linear | 冷 · 紫靛 · 精致中后台 |
| B | Stripe | 冷 · 品牌紫 · 金融精致 |
| C | Airbnb | 暖 · 珊瑚红 · 出行品牌 |
| D | Claude | 暖 · 陶土米 · 人文克制 |
| E | Intercom | 暖 · 米白黑 · 温和 B2B |
每页均为 5 主题 × 浅/暗 = 10 张。皮肤为 token 叠加预览,定稿后再完整落地。
`;
fs.writeFileSync(path.join(outRoot, 'README.md'), indexMd);
await browser.close();
console.log('\n完成 →', outRoot);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -0,0 +1,140 @@
#!/usr/bin/env node
/**
* 融合方案Stripe Fintech Bento + 看板 + 左侧任务/右侧表单主从)三视图截图生成脚本
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(__dirname, '..');
const outDir = path.join(root, 'src/resources/design-system/lease-contract-combo-shots');
async function loadPuppeteer() {
const require = createRequire(import.meta.url);
try {
return require('puppeteer');
} catch {
return require('puppeteer-core');
}
}
const COMBOS = [
{
view: 'list',
viewLabel: '列表模式 (List View)',
desc: 'Stripe Fintech 高端台账列表:显示项目、客户/签约主体、履约与审批状态、交付车辆与金额',
},
{
view: 'kanban',
viewLabel: '看板模式 (Kanban View)',
desc: 'Pipeline 阶段履约看板:草稿箱、待我审批/盖章中、履约执行中、已终止/归档 4 大生命周期列',
},
{
view: 'split',
viewLabel: '主从/表单模式 (Master-Detail View)',
desc: '左侧任务菜单 + 右侧表单内容区:包含合同概览、关联车辆与运单、电子盖章存证与合规审批',
},
];
async function main() {
fs.mkdirSync(outDir, { recursive: true });
const puppeteer = await loadPuppeteer();
const browser = await puppeteer.launch({
headless: true,
executablePath:
process.env.PUPPETEER_EXECUTABLE_PATH ||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
defaultViewport: { width: 1440, height: 1024, deviceScaleFactor: 2 },
args: ['--no-sandbox', '--disable-setuid-sandbox'],
});
const page = await browser.newPage();
page.setDefaultTimeout(60000);
const baseUrl = 'http://127.0.0.1:51720';
const modes = ['light', 'dark'];
const results = [];
for (const combo of COMBOS) {
for (const mode of modes) {
const demoUrl = `${baseUrl}/prototypes/oneos-prototype-demo/?oneosTheme=${mode}#proto=lease-contract-redesign`;
console.log(`正在渲染 Combo [${combo.view}] (${mode})...`);
await page.goto(demoUrl, { waitUntil: 'networkidle2' });
await page.waitForSelector('.oneos-shell', { timeout: 30000 });
await page.waitForSelector('.oneos-shell-frame', { timeout: 30000 });
// 给外壳设置主题属性
await page.evaluate((m) => {
document.documentElement.dataset.dsMode = m;
document.documentElement.dataset.oneosTheme = m;
const shell = document.querySelector('.oneos-shell');
if (shell) shell.dataset.oneosTheme = m;
}, mode);
// 找到子 iframe 并修改 src 为带 ?concept=combo&view=list/kanban/split 的页面
const iframeElement = await page.$('.oneos-shell-frame');
if (iframeElement) {
const targetSrc = `${baseUrl}/prototypes/lease-contract-redesign/?concept=combo&view=${combo.view}&oneosTheme=${mode}`;
await page.evaluate((el, src) => {
el.src = src;
}, iframeElement, targetSrc);
}
// 等待新 iframe 载入并对齐主题
await new Promise((r) => setTimeout(r, 1200));
const childFrame = page.frames().find(
(f) => f !== page.mainFrame() && f.url().includes('lease-contract-redesign'),
);
if (childFrame) {
await childFrame.evaluate((m) => {
document.documentElement.dataset.dsMode = m;
document.documentElement.dataset.oneosTheme = m;
}, mode);
}
await new Promise((r) => setTimeout(r, 400));
const fileName = `combo-${combo.view}-${mode}.png`;
const outPath = path.join(outDir, fileName);
await page.screenshot({ path: outPath, type: 'png', fullPage: false });
console.log('Successfully written:', fileName);
results.push({
fileName,
viewLabel: combo.viewLabel,
desc: combo.desc,
mode: mode === 'light' ? '浅色' : '暗色',
});
}
}
const readme = `# 租赁合同管理 — Stripe Bento 统一三视图 (列表 / 看板 / 左任务右表单) 截图
生成时间:${new Date().toISOString().slice(0, 10)}
底稿风格:以概念 2 (Stripe Fintech UI) 为基础视觉基调,三视图规范统一下的无缝切换体验。
| 文件 | 视图模式 | 模式 | 页面特性说明 |
|------|----------|------|--------------|
${results
.map(
(r) =>
`| \`${r.fileName}\` | **${r.viewLabel}** | ${r.mode} | ${r.desc} |`,
)
.join('\n')}
`;
fs.writeFileSync(path.join(outDir, 'README.md'), readme);
await browser.close();
console.log('三视图融合方案截图全部成功生成并保存至:', outDir);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -0,0 +1,166 @@
#!/usr/bin/env node
/**
* 租赁合同全新 UI/UX 重构设计 (5 套完全不同布局交互) 截图生成器
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(__dirname, '..');
const outDir = path.join(root, 'src/resources/design-system/lease-contract-redesign-shots');
async function loadPuppeteer() {
const require = createRequire(import.meta.url);
try {
return require('puppeteer');
} catch {
return require('puppeteer-core');
}
}
const CONCEPTS = [
{
id: 1,
name: 'Linear 极速命令行与聚焦工作区',
code: 'concept-1-linear',
desc: '快捷键驱动 (⌘K)、右侧抽屉滑动预览、极窄高密表格与微状态指示点',
},
{
id: 2,
name: 'Stripe 金融台账与 Bento 分析枢纽',
code: 'concept-2-stripe',
desc: '高端 Fintech 视觉、Bento Bento Card 分析头图、Segmented Tabs 选项卡',
},
{
id: 3,
name: 'Studio 双栏联动合同工作台',
code: 'concept-3-studio',
desc: '35% 左侧列表 | 65% 右侧完整画卷,零上下文切换,直接在线查阅盖章与运单',
},
{
id: 4,
name: 'Stage Pipeline 阶段履约看板',
code: 'concept-4-kanban',
desc: '4 列生命周期 Pipeline 看板,清晰掌控草稿、审批、履约与归档卡点',
},
{
id: 5,
name: 'Executive 决策视界与优雅卡片阵列',
code: 'concept-5-executive',
desc: 'Apple 高管级 Glassmorphism 渐变半透明大盘、3 列卡片矩阵与氛围高亮',
},
];
async function main() {
fs.mkdirSync(outDir, { recursive: true });
const puppeteer = await loadPuppeteer();
const browser = await puppeteer.launch({
headless: true,
executablePath:
process.env.PUPPETEER_EXECUTABLE_PATH ||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
defaultViewport: { width: 1440, height: 1024, deviceScaleFactor: 2 },
args: ['--no-sandbox', '--disable-setuid-sandbox'],
});
const page = await browser.newPage();
page.setDefaultTimeout(60000);
const baseUrl = 'http://127.0.0.1:51720';
const modes = ['light', 'dark'];
const results = [];
for (const concept of CONCEPTS) {
for (const mode of modes) {
const demoUrl = `${baseUrl}/prototypes/oneos-prototype-demo/?oneosTheme=${mode}#proto=lease-contract-redesign`;
console.log(`正在渲染 Concept ${concept.id} (${mode})...`);
await page.goto(demoUrl, { waitUntil: 'networkidle2' });
await page.waitForSelector('.oneos-shell', { timeout: 30000 });
await page.waitForSelector('.oneos-shell-frame', { timeout: 30000 });
// 给外壳设置主题属性
await page.evaluate((m) => {
document.documentElement.dataset.dsMode = m;
document.documentElement.dataset.oneosTheme = m;
const shell = document.querySelector('.oneos-shell');
if (shell) shell.dataset.oneosTheme = m;
}, mode);
// 找到子 iframe 并修改 src 为带有 ?concept=X 的页面
let childFrame = null;
for (let i = 0; i < 30; i++) {
childFrame = page.frames().find(
(f) => f !== page.mainFrame() && f.url().includes('lease-contract-redesign'),
);
if (childFrame) break;
await new Promise((r) => setTimeout(r, 200));
}
// 切换 iframe 内部 URL 为特定的 concept 与 mode
const iframeElement = await page.$('.oneos-shell-frame');
if (iframeElement) {
const targetSrc = `${baseUrl}/prototypes/lease-contract-redesign/?concept=${concept.id}&oneosTheme=${mode}`;
await page.evaluate((el, src) => {
el.src = src;
}, iframeElement, targetSrc);
}
// 等待新 iframe 载入并对齐主题
await new Promise((r) => setTimeout(r, 1200));
childFrame = page.frames().find(
(f) => f !== page.mainFrame() && f.url().includes('lease-contract-redesign'),
);
if (childFrame) {
await childFrame.evaluate((m) => {
document.documentElement.dataset.dsMode = m;
document.documentElement.dataset.oneosTheme = m;
}, mode);
}
await new Promise((r) => setTimeout(r, 400));
const fileName = `${concept.code}-${mode}.png`;
const outPath = path.join(outDir, fileName);
await page.screenshot({ path: outPath, type: 'png', fullPage: false });
console.log('Successfully written:', fileName);
results.push({
fileName,
conceptId: concept.id,
conceptName: concept.name,
desc: concept.desc,
mode: mode === 'light' ? '浅色' : '暗色',
});
}
}
const readme = `# 租赁合同管理 — 5 套全新 UI/UX 重构方案截图
生成时间:${new Date().toISOString().slice(0, 10)}
统一标准高大上Premium / High-End SaaS、全左侧菜单与顶栏外壳嵌入、100% 浅/暗双模式,支持真正差异化的 UI 布局与交互工作流。
| 文件 | 方案编号与名称 | 模式 | UI / UX 创新亮点 |
|------|----------------|------|------------------|
${results
.map(
(r) =>
`| \`${r.fileName}\` | **Concept ${r.conceptId}**${r.conceptName} | ${r.mode} | ${r.desc} |`,
)
.join('\n')}
`;
fs.writeFileSync(path.join(outDir, 'README.md'), readme);
await browser.close();
console.log('全部 5 套全新的 UI/UX 设计稿已成功生成并保存至:', outDir);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -0,0 +1,206 @@
#!/usr/bin/env node
/**
* 租赁合同列表(含 OneOS 侧栏 + 顶栏)· 5 主题 × 浅/暗 截图
*
* Usage:
* node scripts/shot-lease-contract-themes.mjs
* node scripts/shot-lease-contract-themes.mjs --base=http://127.0.0.1:51720
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
import { THEME_SHOTS, buildSkinCss } from './lease-contract-theme-skins.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(__dirname, '..');
const outDir = path.join(root, 'src/resources/design-system/lease-contract-theme-shots');
function parseArgs(argv) {
const args = { base: 'http://127.0.0.1:51720' };
for (const a of argv) {
if (a.startsWith('--base=')) args.base = a.slice('--base='.length);
}
return args;
}
async function loadPuppeteer() {
const require = createRequire(import.meta.url);
try {
return require('puppeteer');
} catch {
return require('puppeteer-core');
}
}
async function main() {
const args = parseArgs(process.argv.slice(2));
const demoUrl = `${args.base.replace(/\/$/, '')}/prototypes/oneos-prototype-demo/#proto=lease-contract-management`;
fs.mkdirSync(outDir, { recursive: true });
const puppeteer = await loadPuppeteer();
const browser = await puppeteer.launch({
headless: true,
executablePath:
process.env.PUPPETEER_EXECUTABLE_PATH ||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
defaultViewport: { width: 1440, height: 1024, deviceScaleFactor: 2 },
args: ['--no-sandbox', '--disable-setuid-sandbox'],
});
const page = await browser.newPage();
page.setDefaultTimeout(60000);
console.log('打开演示主页', demoUrl);
await page.goto(demoUrl, { waitUntil: 'networkidle2' });
// 1. 等待主 Shell 框架
await page.waitForSelector('.oneos-shell', { timeout: 60000 });
await page.waitForSelector('.oneos-shell-frame', { timeout: 60000 });
// 2. 准确定位真正的子 iframe排除主页面 mainFrame
let childFrame = null;
for (let i = 0; i < 30; i++) {
childFrame = page.frames().find(
(f) => f !== page.mainFrame() && f.url().includes('lease-contract-management'),
);
if (childFrame) {
const ready = await childFrame.evaluate(() => !!document.querySelector('.vm-filter-card'));
if (ready) {
console.log(`子 iframe 页面已真正渲染就绪 (耗时 ${i * 200}ms)`);
break;
}
}
await new Promise((r) => setTimeout(r, 200));
}
if (!childFrame) {
throw new Error('未在演示壳中找到已渲染的子 iframe');
}
const modes = ['light', 'dark'];
const results = [];
for (const theme of THEME_SHOTS) {
for (const mode of modes) {
const css = buildSkinCss(theme, mode);
// A) 给主外壳 (Main Page) 应用主题 css 与浅/深属性
await page.evaluate(
(cssText, modeName) => {
document.documentElement.dataset.dsMode = modeName;
document.documentElement.dataset.oneosTheme = modeName;
const shell = document.querySelector('.oneos-shell');
if (shell) shell.dataset.oneosTheme = modeName;
let el = document.getElementById('ds-theme-shot-skin');
if (!el) {
el = document.createElement('style');
el.id = 'ds-theme-shot-skin';
document.head.appendChild(el);
}
el.textContent = cssText;
// 彻底隐去 ThemeLab 弹框和标注图层
let hideEl = document.getElementById('ds-theme-shot-hide');
if (!hideEl) {
hideEl = document.createElement('style');
hideEl.id = 'ds-theme-shot-hide';
document.head.appendChild(hideEl);
}
hideEl.textContent = `
.ds-theme-lab, .ds-theme-lab--collapsed, [data-annotation-id="lc-theme-lab"],
.axhub-annotation-host, [data-axhub-annotation], .PrototypeAnnotationHost,
iframe[src*="annotation"] {
display: none !important; visibility: hidden !important; pointer-events: none !important;
}
`;
},
css,
mode,
);
// B) 给真正的子 iframe (Child Frame) 应用主题 css 与浅/深属性
await childFrame.evaluate(
(cssText, modeName) => {
document.documentElement.dataset.dsMode = modeName;
document.documentElement.dataset.oneosTheme = modeName;
let el = document.getElementById('ds-theme-shot-skin');
if (!el) {
el = document.createElement('style');
el.id = 'ds-theme-shot-skin';
document.head.appendChild(el);
}
el.textContent = cssText;
// 彻底隐去 ThemeLab 弹框和标注图层
let hideEl = document.getElementById('ds-theme-shot-hide');
if (!hideEl) {
hideEl = document.createElement('style');
hideEl.id = 'ds-theme-shot-hide';
document.head.appendChild(hideEl);
}
hideEl.textContent = `
.ds-theme-lab, .ds-theme-lab--collapsed, [data-annotation-id="lc-theme-lab"],
.axhub-annotation-host, [data-axhub-annotation], .PrototypeAnnotationHost,
iframe[src*="annotation"] {
display: none !important; visibility: hidden !important; pointer-events: none !important;
}
`;
// 展开“更多筛选”呈现完整条件
const expand = document.querySelector(
'button.ldb-filter-toggle, button.vm-btn-link.ldb-filter-toggle',
);
if (expand && expand.textContent.includes('更多')) {
expand.click();
}
},
css,
mode,
);
await new Promise((r) => setTimeout(r, 400));
const file = `${theme.id}-${mode}.png`;
const outPath = path.join(outDir, file);
// 截取完整演示外壳页面
await page.screenshot({ path: outPath, type: 'png', fullPage: false });
console.log('wrote', file);
results.push({ file, theme: theme.name, mode, category: theme.category });
}
}
const readme = `# 租赁合同(含 OneOS 侧栏 + 顶栏)· 5 主题比稿截图
生成时间:${new Date().toISOString().slice(0, 10)}
底稿:演示外壳 \`/prototypes/oneos-prototype-demo/#proto=lease-contract-management\`(包含完整左侧菜单与顶部栏,外壳与内容主题/浅暗 100% 深度绑定)
| 文件 | 主题 | 分类 | 模式 |
|------|------|------|------|
${results.map((r) => `| \`${r.file}\` | ${r.theme} | ${r.category} | ${r.mode === 'light' ? '浅色' : '暗色'} |`).join('\n')}
## 五套主题风格
| 编号 | 主题 | 调性与气质 |
|------|------|------|
| A | Linear | 冷 · 紫靛 · 精致中后台 |
| B | Stripe | 冷 · 品牌紫 · 金融精致 |
| C | Airbnb | 暖 · 珊瑚红 · 出行品牌 |
| D | Claude | 暖 · 陶土米 · 人文克制 |
| E | Intercom | 暖 · 米白黑 · 温和 B2B浅色主按钮用品牌橙 |
> 说明:外壳(侧边栏与顶栏)与内容区(租赁合同)在颜色调性与浅/暗模式下 100% 绑定一致,主题实验室悬浮弹框已移除。
`;
fs.writeFileSync(path.join(outDir, 'README.md'), readme);
await browser.close();
console.log('完成 →', outDir);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -0,0 +1,70 @@
#!/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/annotation-demo',
'/prototypes/beginner-guide',
'/themes/apple',
];
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 previewLoaderMatches = Array.from(
html.matchAll(/src="([^"]*__axhub-preview-loader\.js[^"]*)"/g),
(match) => match[1],
);
const previewLoader = previewLoaderMatches[0] || null;
let loaderScript = '';
if (previewLoader) {
loaderScript = await fetch(new URL(previewLoader, baseUrl)).then((res) => res.text());
}
const ok = response.ok
&& html.includes('<div id="root"></div>')
&& previewLoaderMatches.length === 1
&& !html.includes('html-proxy')
&& !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(` previewLoaderCount=${previewLoaderMatches.length}`);
console.error(` removedHtmlProxy=${!html.includes('html-proxy')}`);
console.error(` removedLegacyLoader=${!html.includes('waitForBootstrap')}`);
console.error(` previewLoader=${Boolean(previewLoader)}`);
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,220 @@
#!/usr/bin/env node
/**
* 将 src/prototypes 下业务原型整套复制为 <id>-legacy供设计规范 v2 对照。
* 排除oneos-prototype-nav、已有 *-legacy
* 复制时跳过:.spec/acp、.spec/prototype-comment-assets体积大、非页面源码
*
* Usage: node scripts/snapshot-prototypes-legacy.mjs
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(__dirname, '..');
const prototypesDir = path.join(root, 'src/prototypes');
const sidebarPath = path.join(root, '.axhub/make/sidebar-tree.json');
const SKIP_DIRS = new Set(['oneos-prototype-nav']);
const RSYNC_EXCLUDES = [
'--exclude=.spec/acp',
'--exclude=.spec/prototype-comment-assets',
'--exclude=node_modules',
'--exclude=.DS_Store',
];
function listSourceIds() {
return fs
.readdirSync(prototypesDir, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => d.name)
.filter((name) => !SKIP_DIRS.has(name) && !name.endsWith('-legacy'))
.sort();
}
function collectTitles(node, map = new Map()) {
if (!node) return map;
if (Array.isArray(node)) {
for (const child of node) collectTitles(child, map);
return map;
}
if (node.kind === 'item' && node.itemKey?.startsWith('prototypes/')) {
const id = node.itemKey.slice('prototypes/'.length);
map.set(id, node.title || id);
}
if (node.children) collectTitles(node.children, map);
if (node.prototypes) collectTitles(node.prototypes, map);
return map;
}
function walkFiles(dir, out = []) {
if (!fs.existsSync(dir)) return out;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === 'node_modules' || entry.name === '.git') continue;
walkFiles(full, out);
} else if (/\.(tsx?|jsx?|css|md|json|html|js)$/i.test(entry.name)) {
out.push(full);
}
}
return out;
}
function rewriteFileContent(content, sourceIds) {
let next = content;
// Longer ids first to avoid partial replaces
const ids = [...sourceIds].sort((a, b) => b.length - a.length);
for (const id of ids) {
const legacy = `${id}-legacy`;
// path segments / imports
next = next.replaceAll(`prototypes/${id}/`, `prototypes/${legacy}/`);
next = next.replaceAll(`prototypes/${id}'`, `prototypes/${legacy}'`);
next = next.replaceAll(`prototypes/${id}"`, `prototypes/${legacy}"`);
next = next.replaceAll(`../${id}/`, `../${legacy}/`);
next = next.replaceAll(`'../${id}'`, `'../${legacy}'`);
next = next.replaceAll(`"../${id}"`, `"../${legacy}"`);
next = next.replaceAll(`/${id}/index.html`, `/${legacy}/index.html`);
}
return next;
}
function patchAnnotationTitles(filePath, legacyId, displayTitle) {
if (!fs.existsSync(filePath)) return;
try {
const raw = fs.readFileSync(filePath, 'utf8');
const doc = JSON.parse(raw);
if (doc && typeof doc === 'object') {
if (typeof doc.title === 'string') doc.title = displayTitle;
if (typeof doc.name === 'string') doc.name = displayTitle;
if (doc.meta && typeof doc.meta === 'object') {
doc.meta.legacyOf = legacyId.replace(/-legacy$/, '');
doc.meta.title = displayTitle;
}
fs.writeFileSync(filePath, `${JSON.stringify(doc, null, 2)}\n`);
}
} catch {
// leave as-is if not JSON object
}
}
function copyOne(id) {
const src = path.join(prototypesDir, id);
const dest = path.join(prototypesDir, `${id}-legacy`);
if (fs.existsSync(dest)) {
fs.rmSync(dest, { recursive: true, force: true });
}
const result = spawnSync(
'rsync',
['-a', ...RSYNC_EXCLUDES, `${src}/`, `${dest}/`],
{ encoding: 'utf8' }
);
if (result.status !== 0) {
// fallback cp
fs.cpSync(src, dest, {
recursive: true,
filter: (p) =>
!p.includes(`${path.sep}.spec${path.sep}acp`) &&
!p.includes(`${path.sep}prototype-comment-assets`) &&
!p.includes(`${path.sep}node_modules`),
});
}
return dest;
}
function addLegacySidebarFolder(sidebar, legacyEntries) {
const folderId = 'folder-ds-v2-legacy-snapshots';
const folder = {
id: folderId,
kind: 'folder',
title: '设计规范对照·旧版',
children: legacyEntries.map(({ id, title }) => ({
id: `item:prototypes:${id}`,
kind: 'item',
title,
itemKey: `prototypes/${id}`,
})),
};
// nav-menu 只同步 title === 'OneOS' 的分区,旧版必须挂在其下
const list = sidebar.prototypes || [];
const oneos = list.find((n) => n.kind === 'folder' && n.title === 'OneOS');
if (!oneos) {
throw new Error('sidebar-tree.json 中未找到 OneOS 分区');
}
oneos.children = Array.isArray(oneos.children) ? oneos.children : [];
// 移除顶层误挂的同名文件夹
sidebar.prototypes = list.filter((n) => n.id !== folderId);
const idx = oneos.children.findIndex((n) => n.id === folderId);
if (idx >= 0) oneos.children[idx] = folder;
else oneos.children.push(folder);
sidebar.updatedAt = new Date().toISOString();
}
function main() {
const sourceIds = listSourceIds();
const sidebar = JSON.parse(fs.readFileSync(sidebarPath, 'utf8'));
const titleMap = collectTitles(sidebar);
console.log(`将复制 ${sourceIds.length} 个原型 → *-legacy`);
for (const id of sourceIds) {
process.stdout.write(` copy ${id} ... `);
copyOne(id);
console.log('ok');
}
const legacyIds = sourceIds.map((id) => `${id}-legacy`);
const allSourceAndLegacy = new Set([...sourceIds, ...legacyIds]);
// Rewrite cross-refs inside legacy trees to stay frozen against live restyles
for (const legacyId of legacyIds) {
const dir = path.join(prototypesDir, legacyId);
const baseId = legacyId.replace(/-legacy$/, '');
const title = `【旧版】${titleMap.get(baseId) || baseId}`;
const files = walkFiles(dir);
for (const file of files) {
const before = fs.readFileSync(file, 'utf8');
let after = rewriteFileContent(before, sourceIds);
if (after !== before) fs.writeFileSync(file, after);
}
patchAnnotationTitles(path.join(dir, 'annotation-source.json'), legacyId, title);
fs.writeFileSync(
path.join(dir, 'LEGACY.md'),
`# ${title}
本目录为设计规范 v2 套用前的**冻结对照副本**${new Date().toISOString().slice(0, 10)})。
- 源原型:\`src/prototypes/${baseId}/\`
- 请勿在此目录继续改业务;正式改造只在源目录进行
- 已跳过复制:\`.spec/acp\`\`.spec/prototype-comment-assets\`(批注大图/会话)
- 交叉引用已尽量改写为同套 \`*-legacy\`,避免引用被改版后的正式样式
`
);
}
const legacyEntries = sourceIds.map((id) => ({
id: `${id}-legacy`,
title: `【旧版】${titleMap.get(id) || id}`,
}));
addLegacySidebarFolder(sidebar, legacyEntries);
fs.writeFileSync(sidebarPath, `${JSON.stringify(sidebar, null, 2)}\n`);
console.log(`已更新 sidebar-tree.json${legacyEntries.length} 个旧版入口)`);
const sync = spawnSync(
'npm',
['run', 'nav:sync', '--', '--note', '注册设计规范 v2 前整套 legacy 对照副本'],
{ cwd: root, encoding: 'utf8', shell: true }
);
console.log(sync.stdout || '');
if (sync.stderr) console.error(sync.stderr);
if (sync.status !== 0) {
console.error('nav:sync 失败,请手动执行 npm run nav:sync');
process.exit(sync.status || 1);
}
console.log('完成。');
}
main();

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,92 @@
/**
* 从各原型 annotation-source.json 移除 ONE-OS 原型导航目录节点。
*
* 用法:
* node scripts/strip-prototype-nav-from-directory.mjs
* node scripts/strip-prototype-nav-from-directory.mjs --prototype lease-contract-management
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const NAV_FOLDER_ID = 'oneos-project-nav';
function parseArgs(argv) {
const args = { prototype: '', projectRoot: '' };
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--prototype' && argv[i + 1]) {
args.prototype = argv[++i].trim();
} else if (arg === '--project-root' && argv[i + 1]) {
args.projectRoot = path.resolve(argv[++i].trim());
}
}
return args;
}
function readJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
}
function writeJson(filePath, value) {
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
}
function stripNavNodes(nodes) {
const list = Array.isArray(nodes) ? nodes : [];
return list
.filter((node) => node?.id !== NAV_FOLDER_ID)
.map((node) => {
if (node?.type === 'folder' && Array.isArray(node.children)) {
return { ...node, children: stripNavNodes(node.children) };
}
return node;
});
}
function listPrototypeIds(projectRoot) {
const prototypesRoot = path.join(projectRoot, 'src/prototypes');
if (!fs.existsSync(prototypesRoot)) return [];
return fs.readdirSync(prototypesRoot, { withFileTypes: true })
.filter((entry) => entry.isDirectory()
&& fs.existsSync(path.join(prototypesRoot, entry.name, 'annotation-source.json')))
.map((entry) => entry.name)
.sort((a, b) => a.localeCompare(b, 'zh-CN'));
}
function stripPrototype(prototypeId, projectRoot) {
const annotationPath = path.join(projectRoot, 'src/prototypes', prototypeId, 'annotation-source.json');
if (!fs.existsSync(annotationPath)) return false;
const annotation = readJson(annotationPath);
const before = JSON.stringify(annotation.directory?.nodes || []);
annotation.directory = {
nodes: stripNavNodes(annotation.directory?.nodes),
};
const after = JSON.stringify(annotation.directory.nodes || []);
if (before === after) {
console.log(`[skip] ${prototypeId}: 无导航节点`);
return false;
}
annotation.data = annotation.data || {};
annotation.data.updatedAt = Date.now();
writeJson(annotationPath, annotation);
console.log(`[strip] ${prototypeId}: 已移除原型导航目录`);
return true;
}
function main() {
const args = parseArgs(process.argv.slice(2));
const projectRoot = args.projectRoot || path.resolve(__dirname, '..');
const targets = args.prototype ? [args.prototype] : listPrototypeIds(projectRoot);
let changed = 0;
for (const prototypeId of targets) {
if (stripPrototype(prototypeId, projectRoot)) changed += 1;
}
console.log(`完成:处理 ${targets.length} 个原型,更新 ${changed} 个 annotation-source.json`);
}
main();

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

View File

@@ -0,0 +1,355 @@
#!/usr/bin/env node
/**
* 从 src/resources/oneos-web-legacy 生成 ONE-OS Web 端原型与侧边栏菜单项。
*
* 用法node scripts/sync-oneos-web-prototypes.mjs
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, '..');
const legacyRoot = path.join(projectRoot, 'src/resources/oneos-web-legacy');
const prototypesRoot = path.join(projectRoot, 'src/prototypes');
const sidebarPath = path.join(projectRoot, '.axhub/make/sidebar-tree.json');
const PAGE_ID_RE = /^[a-z0-9-]+$/u;
const XLL_MODULES = [
{ slug: 'oneos-web-safety-training', title: '业务-司机安全培训', skipCodegen: true },
];
/** 从 ONE-OS Web 合包拆出、由项目内手写 index 维护的独立原型 */
const STANDALONE_MODULES = [
{ slug: 'business-dept-ledger', title: '业务部台账', skipCodegen: true },
{ slug: 'customer-payment-collection', title: '客户回款情况', skipCodegen: true },
{ slug: 'vehicle-maintenance-ledger', title: '车辆维修明细', skipCodegen: true },
{ slug: 'oneos-web-h2-station-site', title: '站点信息', skipCodegen: true },
{ slug: 'oneos-web-h2-station-stats', title: '加氢站数量统计', skipCodegen: true },
{ slug: 'vehicle-return-settlement', title: '还车应结款', skipCodegen: true },
{ slug: 'vehicle-pickup-receivable', title: '提车应收款', skipCodegen: true },
{ slug: 'oneos-web-approval', title: '审批中心', skipCodegen: true },
{ slug: 'oneos-web-approval-initiated', title: '我发起的', skipCodegen: true },
{ slug: 'oneos-web-approval-todo', title: '我的待办', skipCodegen: true },
{ slug: 'oneos-web-approval-done', title: '我的已办', skipCodegen: true },
{ slug: 'oneos-web-approval-cc', title: '我的抄送', skipCodegen: true },
];
const MODULES = [
{ slug: 'oneos-web-workbench', title: '工作台', files: ['工作台.jsx'] },
{ slug: 'oneos-web-login', title: '登录', files: ['登录.jsx'] },
{ slug: 'oneos-web-vehicle-asset', title: '车辆管理', files: ['车辆管理.jsx', '车辆管理-查看.jsx'] },
{ slug: 'oneos-web-contract-template', title: '合同模板管理', files: ['合同模板管理.jsx'] },
{ slug: 'oneos-web-help-center', title: '帮助中心', dir: '帮助中心' },
{
slug: 'oneos-web-finance',
title: '财务管理',
dir: '财务管理',
excludeDirs: ['文档'],
excludeFiles: [
'还车应结款.jsx',
'还车应结款-查看.jsx',
'还车应结款-费用明细.jsx',
'提车应收款.jsx',
'提车应收款-查看.jsx',
'提车应收款-开票信息.jsx',
'提车应收款-审核.jsx',
'提车应收款-提车收款单.jsx',
'提车收款单-编辑.jsx',
],
},
{ slug: 'oneos-web-procurement', title: '采购管理', dir: '采购管理' },
{ slug: 'oneos-web-lease-contract', title: '车辆租赁合同', dir: '车辆租赁合同' },
{ slug: 'oneos-web-h2-station', title: '加氢站管理', dir: '加氢站管理', excludeDirs: ['export-tools', 'AI-加氢站站点信息-complete'], excludeFiles: ['站点信息.jsx'] },
{ slug: 'oneos-web-data-analysis', title: '数据分析', dir: '数据分析', excludeFiles: ['业务部台账.jsx', '客户回款情况.jsx'] },
{ slug: 'oneos-web-ledger-data', title: '台账数据', dir: '台账数据', excludeDirs: ['docs'], excludeFiles: ['车辆维修明细.jsx'] },
{ slug: 'oneos-web-business', title: '业务管理', dir: '业务管理', excludeDirs: ['文档', 'export-tools', 'AI-保险采购-complete'] },
{ slug: 'oneos-web-ops', title: '运维管理', dir: '运维管理' },
{ slug: 'oneos-web-requirements', title: '需求说明', dir: '需求说明', docsOnly: true },
];
function slugifyPageId(relPath) {
const stem = relPath.replace(/\.jsx$/u, '').replace(/[\\/]/g, '-');
const ascii = stem.replace(/[^\x00-\x7F]/gu, (ch) => `u${ch.charCodeAt(0).toString(16)}`);
let id = ascii.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
if (!id || !PAGE_ID_RE.test(id)) {
id = `p${Math.abs(hashCode(relPath)).toString(36)}`;
}
return id.slice(0, 48);
}
function hashCode(value) {
let hash = 0;
for (let i = 0; i < value.length; i += 1) {
hash = ((hash << 5) - hash) + value.charCodeAt(i);
hash |= 0;
}
return hash;
}
function collectJsxFiles(module) {
if (module.docsOnly) {
return [];
}
if (module.files) {
return module.files.map((file) => ({
rel: file,
abs: path.join(legacyRoot, file),
title: file.replace(/\.jsx$/u, ''),
}));
}
const baseDir = path.join(legacyRoot, module.dir);
const results = [];
const walk = (dir, prefix = '') => {
if (!fs.existsSync(dir)) return;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.name.startsWith('.')) continue;
const full = path.join(dir, entry.name);
const rel = path.posix.join(module.dir, prefix, entry.name).replace(/\\/g, '/');
if (entry.isDirectory()) {
if (module.excludeDirs?.includes(entry.name)) continue;
walk(full, prefix ? `${prefix}/${entry.name}` : entry.name);
continue;
}
if (!entry.name.endsWith('.jsx')) continue;
if (module.excludeFiles?.includes(entry.name)) continue;
results.push({
rel,
abs: full,
title: entry.name.replace(/\.jsx$/u, ''),
});
}
};
walk(baseDir);
return results.sort((a, b) => a.rel.localeCompare(b.rel, 'zh-CN'));
}
function prepareLegacyJsx(sourcePath, targetPath) {
const raw = fs.readFileSync(sourcePath, 'utf8');
let content = raw;
if (!/export\s+default\s+Component/u.test(content)) {
content = `${content.trim()}\n\nexport default Component;\n`;
}
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
fs.writeFileSync(targetPath, content, 'utf8');
}
function writeRequirementsPrototype(module) {
const protoDir = path.join(prototypesRoot, module.slug);
fs.mkdirSync(protoDir, { recursive: true });
const indexTsx = `/**
* @name ${module.title}
* 自 ONE-OS web端/需求说明 归档
*/
import React, { useMemo, useState } from 'react';
import { Card, Col, Input, List, Row, Typography } from 'antd';
const DOC_MAP = import.meta.glob('../../resources/oneos-web-legacy/需求说明/**/*', {
query: '?raw',
import: 'default',
eager: true,
});
const FILES = Object.keys(DOC_MAP)
.map((key) => key.replace('../../resources/oneos-web-legacy/需求说明/', ''))
.filter(Boolean)
.sort((a, b) => a.localeCompare(b, 'zh-CN'))
.map((rel) => ({ rel, title: rel }));
export default function OneosWebRequirementsPage() {
const [query, setQuery] = useState('');
const [active, setActive] = useState(FILES[0]?.rel ?? '');
const filtered = useMemo(() => {
const q = query.trim();
if (!q) return FILES;
return FILES.filter((item) => item.title.includes(q));
}, [query]);
const content = useMemo(() => {
if (!active) return '';
const key = \`../../resources/oneos-web-legacy/需求说明/\${active}\`;
return String(DOC_MAP[key] ?? '无法加载文档');
}, [active]);
return (
<Row gutter={16} style={{ padding: 16, minHeight: '100vh', background: '#f5f7fa' }}>
<Col span={7}>
<Card title="${module.title}" extra={<Input.Search allowClear placeholder="搜索文档" onSearch={setQuery} onChange={(e) => setQuery(e.target.value)} style={{ width: 160 }} />}>
<List
size="small"
dataSource={filtered}
renderItem={(item) => (
<List.Item style={{ cursor: 'pointer', background: item.rel === active ? '#e8f3ff' : undefined }} onClick={() => setActive(item.rel)}>
{item.title}
</List.Item>
)}
/>
</Card>
</Col>
<Col span={17}>
<Card>
<Typography.Paragraph style={{ whiteSpace: 'pre-wrap', marginBottom: 0 }}>{content}</Typography.Paragraph>
</Card>
</Col>
</Row>
);
}
`;
fs.writeFileSync(path.join(protoDir, 'index.tsx'), indexTsx, 'utf8');
}
function writePrototype(module, pages) {
const protoDir = path.join(prototypesRoot, module.slug);
const pagesDir = path.join(protoDir, 'pages');
fs.mkdirSync(pagesDir, { recursive: true });
const pageEntries = [];
const importLines = [];
const usedIds = new Set();
pages.forEach((page, index) => {
const fileName = `${String(index + 1).padStart(2, '0')}-${path.basename(page.rel)}`;
const target = path.join(pagesDir, fileName);
prepareLegacyJsx(page.abs, target);
const importName = `Page${index + 1}`;
importLines.push(`import ${importName} from './pages/${fileName}';`);
let id = slugifyPageId(page.rel);
while (usedIds.has(id)) id = `${id}-${index + 1}`;
usedIds.add(id);
pageEntries.push({ id, title: page.title, importName });
});
const pagesArray = pageEntries.map((p) => ` { id: '${p.id}', title: '${p.title.replace(/'/g, "\\'")}', component: ${p.importName} },`).join('\n');
const indexTsx = `/**
* @name ${module.title}
* 自 ONE-OS web端 原稿复刻(${pages.length} 个页面)
*/
import '../../common/oneosWebLegacy/legacyGlobals';
import React from 'react';
${importLines.join('\n')}
import { OneosWebLegacyShell } from '../../common/oneosWebLegacy/OneosWebLegacyShell';
const pages = [
${pagesArray}
];
export default function ${toComponentName(module.slug)}() {
return (
<OneosWebLegacyShell
moduleTitle="${module.title}"
pages={pages}
defaultPageId="${pageEntries[0]?.id ?? ''}"
/>
);
}
`;
fs.writeFileSync(path.join(protoDir, 'index.tsx'), indexTsx, 'utf8');
}
function toComponentName(slug) {
return slug
.split('-')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join('');
}
const WORKBENCH_SIDEBAR_ITEM = {
id: 'item:prototypes:oneos-web-workbench',
kind: 'item',
title: '工作台',
itemKey: 'prototypes/oneos-web-workbench',
};
const ONEOS_NAV_FOLDER_ID = 'folder-1782874576229-r8atbu';
function toSidebarItem(module) {
return {
id: `item:prototypes:${module.slug}`,
kind: 'item',
title: module.title,
itemKey: `prototypes/${module.slug}`,
};
}
function ensureWorkbenchInOneOsRoot(prototypes) {
for (const item of prototypes) {
if (item?.id !== ONEOS_NAV_FOLDER_ID || item.kind !== 'folder' || !Array.isArray(item.children)) {
continue;
}
const rest = item.children.filter((child) => child.itemKey !== WORKBENCH_SIDEBAR_ITEM.itemKey);
item.children = [WORKBENCH_SIDEBAR_ITEM, ...rest];
return;
}
}
function updateSidebar(webModules, xllModules) {
const sidebar = JSON.parse(fs.readFileSync(sidebarPath, 'utf8'));
const prefix = 'item:prototypes:oneos-web-';
const existing = (sidebar.prototypes ?? []).filter(
(item) =>
item.id !== 'folder-prototypes-oneos-web'
&& item.id !== 'folder-prototypes-xll-miniapp'
&& item.itemKey !== 'prototypes/contract-template-management'
&& !String(item.id).startsWith(prefix)
&& !String(item.itemKey).startsWith('prototypes/oneos-web-'),
);
ensureWorkbenchInOneOsRoot(existing);
const webModulesForFolder = webModules.filter((module) => module.slug !== 'oneos-web-workbench');
const xllFolder = {
id: 'folder-prototypes-xll-miniapp',
kind: 'folder',
title: '小羚羚小程序',
children: xllModules.map(toSidebarItem),
};
const oneosFolder = {
id: 'folder-prototypes-oneos-web',
kind: 'folder',
title: 'ONE-OS Web 端',
defaultExpanded: true,
children: webModulesForFolder.map(toSidebarItem),
};
sidebar.prototypes = [...existing, xllFolder, oneosFolder];
sidebar.updatedAt = new Date().toISOString();
fs.writeFileSync(sidebarPath, `${JSON.stringify(sidebar, null, 2)}\n`, 'utf8');
}
function main() {
if (!fs.existsSync(legacyRoot)) {
console.error('缺少 src/resources/oneos-web-legacy请先复制 ONE-OS web端 资料。');
process.exit(1);
}
const allModules = [...XLL_MODULES, ...STANDALONE_MODULES, ...MODULES];
for (const module of allModules) {
if (module.skipCodegen) {
console.log(`[skip codegen] ${module.slug}(入口由项目内手写维护)`);
continue;
}
if (module.docsOnly) {
writeRequirementsPrototype(module);
console.log(`[ok] ${module.slug} (需求说明文档浏览)`);
continue;
}
const pages = collectJsxFiles(module);
if (!pages.length) {
console.warn(`[skip] ${module.slug}: 未找到 jsx 页面`);
continue;
}
writePrototype(module, pages);
console.log(`[ok] ${module.slug} (${pages.length} pages)`);
}
updateSidebar(MODULES, XLL_MODULES);
console.log('已更新 .axhub/make/sidebar-tree.json');
}
main();

39
scripts/sync-project-metadata.d.ts vendored Normal file
View File

@@ -0,0 +1,39 @@
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 const PROTOTYPE_PLACEHOLDER_GUIDE: {
kind: string;
title: string;
description: string;
steps: string[];
tips: 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,651 @@
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'],
themes: ['src/themes'],
media: ['src/resources/assets'],
};
export const resourceWriteTargets = {
prototypes: { type: 'project-relative-path', path: resourceLayout.prototypes[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,
};
export const PROTOTYPE_PLACEHOLDER_GUIDE = {
kind: 'prototype-empty',
title: '这个原型还没有开始创建',
description: '告诉 AI 你想做什么:目标用户、使用场景、页面内容和参考风格。',
steps: [
'在本地 AI 软件中打开本页面',
'打开草稿创作原型',
],
tips: [
'模型不要用 auto推荐Claude Opus 4.8、Gemini 3.1 Pro、GPT-5.5、Kimi K2.7、GLM-5.2。',
'一个任务开一个新对话,避免多个需求互相干扰。',
'多用图片和语音描述,截图、草图和参考页面通常比长文字更清楚。',
'如果已有视觉规范,建议先创建设计系统。',
],
};
// 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 hasGeneratedPlaceholderSource(indexFilePath) {
if (!fs.existsSync(indexFilePath)) return false;
const source = fs.readFileSync(indexFilePath, 'utf8');
const hasGeneratedShell = source.includes('placeholder-empty-page')
&& source.includes('打开左侧默认引导页继续创建')
&& source.includes('export default function Placeholder');
return hasGeneratedShell && (
source.includes('@axhub-placeholder prototype-empty')
|| source.includes('className="placeholder-empty-page"')
);
}
function hasEmptyCanvasFile(prototypeDir) {
const canvasPath = path.join(prototypeDir, 'canvas.excalidraw');
if (!fs.existsSync(canvasPath)) return true;
try {
const canvas = JSON.parse(fs.readFileSync(canvasPath, 'utf8'));
const elements = Array.isArray(canvas?.elements) ? canvas.elements : [];
const files = canvas?.files && typeof canvas.files === 'object' && !Array.isArray(canvas.files)
? canvas.files
: {};
return elements.length === 0 && Object.keys(files).length === 0;
} catch {
return false;
}
}
function isGeneratedEmptyPrototypePlaceholder(prototypeDir, indexFilePath) {
return hasGeneratedPlaceholderSource(indexFilePath) && hasEmptyCanvasFile(prototypeDir);
}
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 placeholder = isGeneratedEmptyPrototypePlaceholder(path.join(root, entry.name), indexFile);
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 } : {}),
...(placeholder ? { placeholder: true, placeholderGuide: PROTOTYPE_PLACEHOLDER_GUIDE } : {}),
};
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 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 themes = collectThemes(projectRoot, clientOrigin);
return {
schemaVersion: 1,
project: {
id: projectIdentity.id,
name: projectIdentity.name,
},
resources: {
prototypes,
themes,
},
navigation: {
prototypes: prototypes.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}`);
}

39
scripts/sync-project-metadata.mjs.d.ts vendored Normal file
View File

@@ -0,0 +1,39 @@
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 const PROTOTYPE_PLACEHOLDER_GUIDE: {
kind: string;
title: string;
description: string;
steps: string[];
tips: 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,160 @@
/**
* 将 .axhub/make/sidebar-tree.json 同步到原型导航页 nav-menu.json。
* 不再向各原型 annotation-source.json 注入「ONE-OS 原型导航」目录。
*
* 用法:
* node scripts/sync-project-prototype-directory.mjs
* node scripts/sync-project-prototype-directory.mjs --prototype vehicle-management
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const NAV_FOLDER_ID = 'oneos-project-nav';
function parseArgs(argv) {
const args = { prototype: '', projectRoot: '' };
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--prototype' && argv[i + 1]) {
args.prototype = argv[++i].trim();
} else if (arg === '--project-root' && argv[i + 1]) {
args.projectRoot = path.resolve(argv[++i].trim());
}
}
return args;
}
function readJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
}
function writeJson(filePath, value) {
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
}
function loadSidebarPrototypes(projectRoot) {
const sidebarPath = path.join(projectRoot, '.axhub/make/sidebar-tree.json');
if (!fs.existsSync(sidebarPath)) {
throw new Error(`缺少侧边栏配置:${sidebarPath}`);
}
const sidebar = readJson(sidebarPath);
return Array.isArray(sidebar.prototypes) ? sidebar.prototypes : [];
}
function listPrototypeIdsWithAnnotation(projectRoot) {
const prototypesRoot = path.join(projectRoot, 'src/prototypes');
if (!fs.existsSync(prototypesRoot)) return [];
return fs.readdirSync(prototypesRoot, { withFileTypes: true })
.filter((entry) => entry.isDirectory()
&& fs.existsSync(path.join(prototypesRoot, entry.name, 'annotation-source.json')))
.map((entry) => entry.name)
.sort((a, b) => a.localeCompare(b, 'zh-CN'));
}
function stripNavNodes(nodes) {
const list = Array.isArray(nodes) ? nodes : [];
return list
.filter((node) => node?.id !== NAV_FOLDER_ID)
.map((node) => {
if (node?.type === 'folder' && Array.isArray(node.children)) {
return { ...node, children: stripNavNodes(node.children) };
}
return node;
});
}
function fixPublishedLinks(nodes, prototypeId) {
const walk = (items) => {
for (const node of items || []) {
if (node?.type === 'link' && typeof node.href === 'string') {
const href = node.href.trim();
const selfMatch = href.match(new RegExp(`^/prototypes/${prototypeId.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&')}(?:[#?].*)?$`, 'u'));
if (selfMatch) {
node.href = './index.html';
node.target = node.target || 'self';
} else {
const otherMatch = href.match(/^\/prototypes\/([^/?#]+)(?:[#?].*)?$/u);
if (otherMatch) {
node.href = `../${otherMatch[1]}/index.html`;
node.target = node.target || 'self';
}
}
}
if (node?.type === 'folder' && Array.isArray(node.children)) {
walk(node.children);
}
}
};
walk(nodes);
}
function cleanupPrototypeDirectory(prototypeId, projectRoot) {
const annotationPath = path.join(projectRoot, 'src/prototypes', prototypeId, 'annotation-source.json');
if (!fs.existsSync(annotationPath)) {
console.log(`[skip] ${prototypeId}: 无 annotation-source.json`);
return false;
}
const annotation = readJson(annotationPath);
const existingNodes = Array.isArray(annotation.directory?.nodes) ? annotation.directory.nodes : [];
const cleanedNodes = stripNavNodes(existingNodes);
fixPublishedLinks(cleanedNodes, prototypeId);
const before = JSON.stringify(existingNodes);
const after = JSON.stringify(cleanedNodes);
if (before === after) {
console.log(`[ok] ${prototypeId}: 目录已不含原型导航`);
return false;
}
annotation.directory = { nodes: cleanedNodes };
annotation.data = annotation.data || {};
annotation.data.updatedAt = Date.now();
writeJson(annotationPath, annotation);
console.log(`[cleanup] ${prototypeId}: 已移除原型导航目录节点`);
return true;
}
function filterNavMenuPrototypes(sidebarItems) {
return sidebarItems.filter((node) => node?.kind === 'folder' && node?.title === 'OneOS');
}
function syncPrototypeNavMenu(projectRoot, sidebarItems) {
const navMenuPath = path.join(projectRoot, 'src/prototypes/oneos-prototype-nav/nav-menu.json');
if (!fs.existsSync(path.dirname(navMenuPath))) return false;
const filtered = filterNavMenuPrototypes(sidebarItems);
writeJson(navMenuPath, { prototypes: filtered });
console.log(`[sync] oneos-prototype-nav: 已更新 nav-menu.json${filtered.length} 个分区)`);
return true;
}
function main() {
const args = parseArgs(process.argv.slice(2));
const projectRoot = args.projectRoot || path.resolve(__dirname, '..');
const sidebarItems = loadSidebarPrototypes(projectRoot);
const targets = args.prototype
? [args.prototype]
: listPrototypeIdsWithAnnotation(projectRoot);
if (!sidebarItems.length) {
console.error('sidebar-tree.json 中未找到 prototypes 菜单。');
process.exit(1);
}
if (!targets.length) {
console.log('未找到带 annotation-source.json 的原型。');
process.exit(0);
}
let changed = 0;
for (const prototypeId of targets) {
if (cleanupPrototypeDirectory(prototypeId, projectRoot)) {
changed += 1;
}
}
console.log(`完成:处理 ${targets.length} 个原型,清理 ${changed} 个 annotation-source.json`);
syncPrototypeNavMenu(projectRoot, sidebarItems);
}
main();

View File

@@ -0,0 +1,358 @@
/**
* 原型导航注册表:检测原型变更、递增版本、写入变更日志,并同步 nav-menu。
*
* 用法:
* node scripts/sync-prototype-nav-registry.mjs
* node scripts/sync-prototype-nav-registry.mjs --prototype vehicle-h2-fee-ledger
* node scripts/sync-prototype-nav-registry.mjs --prototype vehicle-h2-fee-ledger --note "成本单价校验"
* CHANGELOG_NOTE="修复筛选" node scripts/sync-prototype-nav-registry.mjs --prototype customer-management
*/
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const NAV_PROTOTYPE_ID = 'oneos-prototype-nav';
const REGISTRY_RELATIVE = 'src/prototypes/oneos-prototype-nav/prototype-registry.json';
const TRACKED_EXTENSIONS = new Set(['.tsx', '.ts', '.jsx', '.js', '.css', '.json', '.md', '.html']);
const IGNORED_DIR_NAMES = new Set(['node_modules', '.git', 'dist', 'coverage']);
const IGNORED_FILE_NAMES = new Set(['prototype-registry.json', 'nav-menu.json']);
const MAX_CHANGELOG_PER_PROTOTYPE = 30;
const MAX_RECENT_UPDATES = 40;
function parseArgs(argv) {
const args = {
prototype: '',
projectRoot: '',
note: process.env.CHANGELOG_NOTE?.trim() || '',
quiet: false,
skipDirectorySync: false,
};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--prototype' && argv[i + 1]) {
args.prototype = argv[++i].trim();
} else if (arg === '--project-root' && argv[i + 1]) {
args.projectRoot = path.resolve(argv[++i].trim());
} else if (arg === '--note' && argv[i + 1]) {
args.note = argv[++i].trim();
} else if (arg === '--quiet') {
args.quiet = true;
} else if (arg === '--skip-directory-sync') {
args.skipDirectorySync = true;
}
}
return args;
}
function readJson(filePath, fallback = null) {
if (!fs.existsSync(filePath)) return fallback;
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
}
function writeJson(filePath, value) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
}
function log(message, quiet) {
if (!quiet) console.log(message);
}
function prototypeIdFromItemKey(itemKey) {
const normalized = String(itemKey || '').trim().replace(/^\/+/u, '');
const match = normalized.match(/^prototypes\/(.+)$/u);
return match ? match[1] : '';
}
function loadSidebarPrototypes(projectRoot) {
const sidebarPath = path.join(projectRoot, '.axhub/make/sidebar-tree.json');
if (!fs.existsSync(sidebarPath)) {
throw new Error(`缺少侧边栏配置:${sidebarPath}`);
}
const sidebar = readJson(sidebarPath, {});
return Array.isArray(sidebar.prototypes) ? sidebar.prototypes : [];
}
function collectNavPrototypeTitles(sidebarItems) {
const titles = new Map();
const walk = (items) => {
for (const item of items || []) {
if (item?.kind === 'item') {
const prototypeId = prototypeIdFromItemKey(item.itemKey);
if (prototypeId && prototypeId !== NAV_PROTOTYPE_ID) {
titles.set(prototypeId, item.title || prototypeId);
}
}
if (Array.isArray(item?.children)) walk(item.children);
}
};
walk(sidebarItems);
return titles;
}
function shouldTrackFile(relativePath) {
const base = path.basename(relativePath);
if (IGNORED_FILE_NAMES.has(base)) return false;
if (relativePath === 'annotation-source.json') return false;
const ext = path.extname(relativePath).toLowerCase();
return TRACKED_EXTENSIONS.has(ext);
}
function listTrackedFiles(prototypeDir) {
const files = [];
const walk = (dir, prefix = '') => {
if (!fs.existsSync(dir)) return;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (IGNORED_DIR_NAMES.has(entry.name)) continue;
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
const abs = path.join(dir, entry.name);
if (entry.isDirectory()) {
walk(abs, rel);
continue;
}
if (shouldTrackFile(rel)) files.push(rel);
}
};
walk(prototypeDir);
return files.sort((a, b) => a.localeCompare(b));
}
function hashPrototypeDir(prototypeDir) {
const files = listTrackedFiles(prototypeDir);
const hash = crypto.createHash('sha256');
hash.update(`files:${files.length}\n`);
for (const rel of files) {
const abs = path.join(prototypeDir, rel);
const stat = fs.statSync(abs);
hash.update(`${rel}\n${stat.mtimeMs}\n${stat.size}\n`);
hash.update(fs.readFileSync(abs));
hash.update('\n');
}
return { hash: hash.digest('hex'), files };
}
function formatNowParts(date = new Date()) {
const pad = (n) => String(n).padStart(2, '0');
return {
iso: date.toISOString(),
date: `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`,
time: `${pad(date.getHours())}:${pad(date.getMinutes())}`,
label: `${date.getMonth() + 1}${date.getDate()}`,
};
}
function summarizeChange(changedFiles, note) {
if (note) return note;
const names = changedFiles.map((f) => path.basename(f)).join(' ');
if (/PRD|requirements|需求/i.test(names) || changedFiles.some((f) => f.includes('.spec/') && f.endsWith('.md'))) {
return '更新需求说明与标注';
}
if (changedFiles.some((f) => f.endsWith('.css'))) {
return '更新页面样式';
}
if (changedFiles.some((f) => /\.(tsx|jsx)$/.test(f) || f.includes('/pages/'))) {
return '更新页面逻辑与交互';
}
if (changedFiles.length === 1) {
return `更新 ${path.basename(changedFiles[0])}`;
}
return `更新 ${changedFiles.length} 个文件`;
}
function diffChangedFiles(previousFiles, currentFiles) {
const prevSet = new Set(previousFiles || []);
return currentFiles.filter((file) => !prevSet.has(file));
}
/** 仅标注/批注文件变动时不自动递增版本(避免 dev 保存刷屏) */
function isAnnotationOnlyChange(changedFiles) {
if (!changedFiles.length) return false;
return changedFiles.every((file) => (
file === 'annotation-source.json'
|| file.endsWith('/annotation-source.json')
|| file.includes('prototype-comments')
|| (file.includes('.spec/') && file.endsWith('.md') && !file.includes('requirements-prd'))
));
}
function bumpVersion(revision) {
return `v1.${revision}`;
}
function createRegistrySkeleton() {
return {
version: 1,
updatedAt: new Date().toISOString(),
prototypes: {},
recentUpdates: [],
};
}
function upsertPrototypeRecord(registry, prototypeId, title, contentHash, files, options) {
const existing = registry.prototypes[prototypeId] || {
title,
version: 'v1.0',
revision: 0,
lastUpdated: null,
contentHash: '',
trackedFiles: [],
changelog: [],
};
const changed = existing.contentHash && existing.contentHash !== contentHash;
const now = formatNowParts();
if (!existing.contentHash) {
existing.title = title;
existing.contentHash = contentHash;
existing.trackedFiles = files;
existing.lastUpdated = now.iso;
registry.prototypes[prototypeId] = existing;
return { changed: false, entry: null };
}
if (!changed) {
existing.title = title;
existing.trackedFiles = files;
registry.prototypes[prototypeId] = existing;
return { changed: false, entry: null };
}
const changedFiles = diffChangedFiles(existing.trackedFiles, files);
if (!options.note && isAnnotationOnlyChange(changedFiles.length ? changedFiles : files)) {
existing.title = title;
existing.contentHash = contentHash;
existing.trackedFiles = files;
registry.prototypes[prototypeId] = existing;
return { changed: false, entry: null };
}
const summary = summarizeChange(changedFiles.length ? changedFiles : files, options.note);
const nextRevision = (existing.revision || 0) + 1;
const version = bumpVersion(nextRevision);
const entry = {
version,
date: now.date,
time: now.time,
summary,
files: (changedFiles.length ? changedFiles : files).slice(0, 12),
};
existing.title = title;
existing.version = version;
existing.revision = nextRevision;
existing.lastUpdated = now.iso;
existing.contentHash = contentHash;
existing.trackedFiles = files;
existing.changelog = [entry, ...(existing.changelog || [])].slice(0, MAX_CHANGELOG_PER_PROTOTYPE);
registry.prototypes[prototypeId] = existing;
return { changed: true, entry: { prototypeId, title, ...entry } };
}
function pushRecentUpdate(registry, update) {
if (!update) return;
registry.recentUpdates = [
update,
...(registry.recentUpdates || []).filter((item) => !(
item.prototypeId === update.prototypeId && item.version === update.version
)),
].slice(0, MAX_RECENT_UPDATES);
}
function syncNavMenuAndDirectories(projectRoot, quiet) {
const script = path.join(projectRoot, 'scripts/sync-project-prototype-directory.mjs');
const result = spawnSync(process.execPath, [script, `--project-root=${projectRoot}`], {
cwd: projectRoot,
encoding: 'utf8',
});
if (result.status !== 0) {
const err = result.stderr || result.stdout || 'unknown error';
throw new Error(`同步原型目录失败:${err}`);
}
if (!quiet && result.stdout?.trim()) {
console.log(result.stdout.trim());
}
}
function syncXllNavMenu(projectRoot, quiet) {
const script = path.join(projectRoot, 'scripts/sync-xll-nav-menu.mjs');
if (!fs.existsSync(script)) return false;
const result = spawnSync(process.execPath, [script, `--project-root=${projectRoot}`], {
cwd: projectRoot,
encoding: 'utf8',
});
if (result.status !== 0) {
const err = result.stderr || result.stdout || 'unknown error';
throw new Error(`同步小羚羚导航失败:${err}`);
}
if (!quiet && result.stdout?.trim()) {
console.log(result.stdout.trim());
}
return true;
}
export function syncPrototypeNavRegistry(options = {}) {
const projectRoot = options.projectRoot || path.resolve(__dirname, '..');
const registryPath = path.join(projectRoot, REGISTRY_RELATIVE);
const sidebarItems = loadSidebarPrototypes(projectRoot);
const titleMap = collectNavPrototypeTitles(sidebarItems);
const registry = readJson(registryPath, createRegistrySkeleton()) || createRegistrySkeleton();
if (!options.skipDirectorySync) {
syncNavMenuAndDirectories(projectRoot, options.quiet);
syncXllNavMenu(projectRoot, options.quiet);
}
const targetIds = options.prototype
? [options.prototype]
: [...titleMap.keys()];
let bumped = 0;
for (const prototypeId of targetIds) {
if (prototypeId === NAV_PROTOTYPE_ID && options.prototype !== NAV_PROTOTYPE_ID) continue;
const title = titleMap.get(prototypeId) || prototypeId;
const prototypeDir = path.join(projectRoot, 'src/prototypes', prototypeId);
if (!fs.existsSync(prototypeDir)) {
log(`[skip] ${prototypeId}: 目录不存在`, options.quiet);
continue;
}
const { hash, files } = hashPrototypeDir(prototypeDir);
const result = upsertPrototypeRecord(registry, prototypeId, title, hash, files, {
note: options.note && options.prototype === prototypeId ? options.note : '',
});
if (result.changed) {
bumped += 1;
pushRecentUpdate(registry, result.entry);
log(`[version] ${prototypeId}: ${result.entry.version}${result.entry.summary}`, options.quiet);
}
}
registry.updatedAt = new Date().toISOString();
writeJson(registryPath, registry);
return { bumped, registryPath, prototypeCount: targetIds.length };
}
function main() {
const args = parseArgs(process.argv.slice(2));
const projectRoot = args.projectRoot || path.resolve(__dirname, '..');
const result = syncPrototypeNavRegistry({
projectRoot,
prototype: args.prototype,
note: args.note,
quiet: args.quiet,
skipDirectorySync: args.skipDirectorySync,
});
log(`完成:检查 ${result.prototypeCount} 个原型,新增版本 ${result.bumped}`, args.quiet);
}
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isMain) {
main();
}

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,132 @@
/**
* 从小羚羚「小程序」项目同步页面目录到 oneos 原型导航。
*
* 用法:
* node scripts/sync-xll-nav-menu.mjs
* node scripts/sync-xll-nav-menu.mjs --project-root /path/to/oneos1.2
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROUTE_TO_PAGE = {
todo: 'todo',
business: 'business',
map: 'map',
mine: 'mine',
audit: 'audit',
delivery: 'delivery',
inspection: 'inspection',
vehicle: 'vehicle',
thirdReturn: 'third-return',
'third-return': 'third-return',
replace: 'replace',
'audit-return': 'audit-return',
training: 'driver-training',
'driver-training': 'driver-training',
};
function parseArgs(argv) {
const args = { projectRoot: '' };
for (let i = 0; i < argv.length; i += 1) {
if (argv[i] === '--project-root' && argv[i + 1]) {
args.projectRoot = path.resolve(argv[++i].trim());
}
}
return args;
}
function readJson(filePath, fallback = null) {
if (!fs.existsSync(filePath)) return fallback;
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
}
function writeJson(filePath, value) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
}
function resolvePageId(route) {
const key = String(route || '').trim();
return ROUTE_TO_PAGE[key] || key;
}
function buildLinksFromFolder(folder) {
const links = [];
for (const child of folder.children || []) {
if (child?.type !== 'route' || !child.route) continue;
const pageId = resolvePageId(child.route);
links.push({
id: child.id || `xll-route-${pageId}`,
title: child.title || pageId,
pageId,
route: child.route,
});
}
return links;
}
export function syncXllNavMenu(projectRoot) {
const oneosRoot = projectRoot || path.resolve(__dirname, '..');
const sourceConfigPath = path.join(oneosRoot, 'src/prototypes/oneos-prototype-nav/xll-nav-source.json');
const outputPath = path.join(oneosRoot, 'src/prototypes/oneos-prototype-nav/xll-nav-menu.json');
const sourceConfig = readJson(sourceConfigPath);
if (!sourceConfig?.projectRoot) {
throw new Error(`缺少小羚羚同步配置:${sourceConfigPath}`);
}
const xllRoot = path.resolve(sourceConfig.projectRoot);
const annotationPath = path.join(xllRoot, sourceConfig.annotationSource);
const annotation = readJson(annotationPath);
if (!annotation) {
throw new Error(`无法读取小羚羚标注目录:${annotationPath}`);
}
const includeIds = new Set(sourceConfig.includeDirectoryIds || ['directory-main', 'directory-modules']);
const groups = [];
for (const node of annotation.directory?.nodes || []) {
if (node?.type !== 'folder' || !includeIds.has(node.id)) continue;
const links = buildLinksFromFolder(node);
if (!links.length) continue;
groups.push({
id: node.id,
title: node.title || '分组',
defaultExpanded: node.defaultExpanded !== false,
links,
});
}
const devInfoPath = path.join(xllRoot, sourceConfig.devServerInfo || '.axhub/make/.dev-server-info.json');
const devInfo = readJson(devInfoPath, {});
const runtimeOrigin = String(devInfo.origin || '').trim() || 'http://localhost:51721';
const prototypeId = sourceConfig.prototypeId || 'xll-miniapp';
const menu = {
version: 1,
updatedAt: new Date().toISOString(),
title: '小羚羚',
sectionId: 'folder-prototypes-xll-miniapp',
description: '氢能车辆运营移动端原型;菜单与小羚羚「小程序」项目目录同步。',
prototypeId,
runtimeOrigin,
hrefPrefix: `${runtimeOrigin.replace(/\/$/u, '')}/prototypes/${prototypeId}`,
groups,
};
writeJson(outputPath, menu);
return { outputPath, groupCount: groups.length, linkCount: groups.reduce((n, g) => n + g.links.length, 0) };
}
function main() {
const args = parseArgs(process.argv.slice(2));
const result = syncXllNavMenu(args.projectRoot);
console.log(`[sync] xll-nav-menu: ${result.groupCount} 个分组,${result.linkCount} 个页面入口`);
console.log(`[sync] 写入 ${result.outputPath}`);
}
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isMain) {
main();
}

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