初始化项目版本
This commit is contained in:
115
scripts/ai-studio-converter.mjs
Normal file
115
scripts/ai-studio-converter.mjs
Normal file
@@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
function normalizeSlashes(input) {
|
||||
return String(input || '').replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
function sanitizeName(rawName) {
|
||||
return String(rawName || '')
|
||||
.replace(/[^a-z0-9-]/gi, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = [...argv];
|
||||
const projectDirArg = args.shift();
|
||||
const outputNameArg = args.shift();
|
||||
let targetType = 'prototypes';
|
||||
let projectRoot = process.cwd();
|
||||
let outputBaseDir = '';
|
||||
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
if (arg === '--target-type') {
|
||||
targetType = String(args[index + 1] || '').trim();
|
||||
index += 1;
|
||||
} else if (arg === '--project-root') {
|
||||
projectRoot = path.resolve(args[index + 1] || projectRoot);
|
||||
index += 1;
|
||||
} else if (arg === '--output-base-dir') {
|
||||
outputBaseDir = path.resolve(args[index + 1] || '');
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (!projectDirArg) throw new Error('Missing project directory');
|
||||
const outputName = sanitizeName(outputNameArg || path.basename(projectDirArg));
|
||||
if (!outputName) throw new Error('Missing valid output name');
|
||||
return {
|
||||
projectDir: path.resolve(projectRoot, projectDirArg),
|
||||
outputName,
|
||||
targetType,
|
||||
projectRoot,
|
||||
outputBaseDir: outputBaseDir || path.resolve(projectRoot, 'src', targetType),
|
||||
};
|
||||
}
|
||||
|
||||
function copyDirectory(src, dest) {
|
||||
if (!fs.existsSync(src)) return 0;
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
let count = 0;
|
||||
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
||||
if (entry.name === 'node_modules') continue;
|
||||
const srcPath = path.join(src, entry.name);
|
||||
const destPath = path.join(dest, entry.name);
|
||||
if (entry.isDirectory()) count += copyDirectory(srcPath, destPath);
|
||||
else if (entry.isFile()) {
|
||||
fs.copyFileSync(srcPath, destPath);
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function ensureIndex(outputDir) {
|
||||
const indexPath = path.join(outputDir, 'index.tsx');
|
||||
if (!fs.existsSync(indexPath)) {
|
||||
fs.writeFileSync(indexPath, [
|
||||
"import React from 'react';",
|
||||
'',
|
||||
'export default function ImportedAiStudioPrototype() {',
|
||||
' return <div data-import-source="google_aistudio">AI Studio import requires AI conversion.</div>;',
|
||||
'}',
|
||||
'',
|
||||
].join('\n'), 'utf8');
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const parsed = parseArgs(process.argv.slice(2));
|
||||
if (!fs.existsSync(path.join(parsed.projectDir, 'App.tsx')) && !fs.existsSync(path.join(parsed.projectDir, 'index.html'))) {
|
||||
throw new Error('这不是一个有效的 AI Studio 项目(缺少 App.tsx 或 index.html)');
|
||||
}
|
||||
const outputDir = path.join(parsed.outputBaseDir, parsed.outputName);
|
||||
fs.rmSync(outputDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(parsed.outputBaseDir, { recursive: true });
|
||||
const fileCount = copyDirectory(parsed.projectDir, outputDir);
|
||||
ensureIndex(outputDir);
|
||||
const taskPath = path.join(outputDir, parsed.targetType === 'themes' ? '.ai-studio-theme-tasks.md' : '.ai-studio-tasks.md');
|
||||
fs.writeFileSync(taskPath, [
|
||||
'# AI Studio 项目转换任务清单',
|
||||
'',
|
||||
'> 请先阅读 `rules/ai-studio-project-converter.md` 和 `rules/development-guide.md`,再基于该目录完成原型转换。',
|
||||
'',
|
||||
`- 输出目录:\`${normalizeSlashes(path.relative(parsed.projectRoot, outputDir))}/\``,
|
||||
`- 已复制文件数:${fileCount}`,
|
||||
'',
|
||||
].join('\n'), 'utf8');
|
||||
console.log(JSON.stringify({
|
||||
success: true,
|
||||
outputDir,
|
||||
tasksFile: normalizeSlashes(path.relative(parsed.projectRoot, taskPath)),
|
||||
}));
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
console.error(error?.message || String(error));
|
||||
process.exit(1);
|
||||
}
|
||||
293
scripts/apply-release-changelog.mjs
Normal file
293
scripts/apply-release-changelog.mjs
Normal 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: [
|
||||
'【平台】全项目接入 PrototypeAnnotationHost,PRD 迁入右侧标注目录;列表统一 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: '还车应结算 V2:OperationActions 统一为详情主操作',
|
||||
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
48
scripts/build-all.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import { spawnSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { scanProjectEntries, writeEntriesManifestAtomic } from '../vite-plugins/utils/entriesManifestCore.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const workspaceRoot = path.resolve(__dirname, '..');
|
||||
const entriesPath = path.resolve(workspaceRoot, '.axhub/make/entries.json');
|
||||
|
||||
// Always rescan before build to keep entries manifest fresh and deterministic.
|
||||
const scanned = scanProjectEntries(workspaceRoot, ['prototypes', 'themes']);
|
||||
const entries = writeEntriesManifestAtomic(workspaceRoot, scanned);
|
||||
if (!fs.existsSync(entriesPath)) {
|
||||
console.error('.axhub/make/entries.json 写入失败,无法继续构建。');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const jsEntries = entries.js || {};
|
||||
const entryKeys = Object.keys(jsEntries);
|
||||
|
||||
if (entryKeys.length === 0) {
|
||||
console.log('未发现 JS 入口,跳过构建。');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const distDir = path.resolve(workspaceRoot, 'dist');
|
||||
if (fs.existsSync(distDir)) {
|
||||
fs.rmSync(distDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
for (const key of entryKeys) {
|
||||
console.log(`\n==== 构建入口: ${key} ====\n`);
|
||||
const result = spawnSync('npx', ['vite', 'build'], {
|
||||
cwd: workspaceRoot,
|
||||
env: { ...process.env, ENTRY_KEY: key },
|
||||
stdio: 'inherit'
|
||||
});
|
||||
|
||||
if (result.status !== 0) {
|
||||
console.error(`构建 ${key} 失败,退出码 ${result.status}`);
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n所有入口构建完成 ✅');
|
||||
2
scripts/canvas-fig-sync.mjs
Normal file
2
scripts/canvas-fig-sync.mjs
Normal file
@@ -0,0 +1,2 @@
|
||||
#!/usr/bin/env node
|
||||
import '../../vendor/axhub-export-core/scripts/canvas-fig-sync.mjs';
|
||||
1025
scripts/capture-theme-homepage.mjs
Normal file
1025
scripts/capture-theme-homepage.mjs
Normal file
File diff suppressed because it is too large
Load Diff
272
scripts/capture-theme-homepage.test.mjs
Normal file
272
scripts/capture-theme-homepage.test.mjs
Normal file
@@ -0,0 +1,272 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
findWorkspaceRoot,
|
||||
normalizeViewport,
|
||||
parseCaptureArgs,
|
||||
resolveChromiumLaunchOptions,
|
||||
resolveCaptureJobs,
|
||||
resolveScreenshotBox,
|
||||
resolveScreenshotPlan,
|
||||
resolveWidthNormalization,
|
||||
} from './capture-theme-homepage.mjs';
|
||||
|
||||
function makeFixture() {
|
||||
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'theme-capture-'));
|
||||
const appRoot = path.join(workspaceRoot, 'apps', 'make-project');
|
||||
const themeRoot = path.join(appRoot, 'src', 'themes', 'linear');
|
||||
fs.mkdirSync(themeRoot, { recursive: true });
|
||||
fs.writeFileSync(path.join(workspaceRoot, 'pnpm-workspace.yaml'), 'packages: []\n');
|
||||
fs.writeFileSync(
|
||||
path.join(themeRoot, 'theme.json'),
|
||||
JSON.stringify({
|
||||
source: {
|
||||
websiteUrl: 'https://linear.app/',
|
||||
originalDetailUrl: 'https://getdesign.md/linear.app/design-md',
|
||||
},
|
||||
}, null, 2),
|
||||
);
|
||||
return { workspaceRoot, appRoot };
|
||||
}
|
||||
|
||||
describe('capture-theme-homepage CLI planning', () => {
|
||||
it('parses screenshot options needed for stable long homepage captures', () => {
|
||||
const args = parseCaptureArgs([
|
||||
'node',
|
||||
'capture-theme-homepage.mjs',
|
||||
'--',
|
||||
'--theme',
|
||||
'linear',
|
||||
'--url',
|
||||
'https://linear.app',
|
||||
'--viewport',
|
||||
'1440x1200',
|
||||
'--wait-for-selector',
|
||||
'main',
|
||||
'--dismiss-selector',
|
||||
'button:has-text("Accept")',
|
||||
'--remove-selector',
|
||||
'.newsletter-modal',
|
||||
'--header',
|
||||
'Authorization=Bearer token',
|
||||
'--connect-cdp',
|
||||
'http://localhost:9222',
|
||||
'--storage-state',
|
||||
'.local/auth/linear.json',
|
||||
'--hide-scrollbar',
|
||||
'--scroll-warmup',
|
||||
'--quality',
|
||||
'92',
|
||||
]);
|
||||
|
||||
expect(args.theme).toBe('linear');
|
||||
expect(args.url).toBe('https://linear.app');
|
||||
expect(args.viewport).toEqual({ width: 1440, height: 1200 });
|
||||
expect(args.waitForSelectors).toEqual(['main']);
|
||||
expect(args.dismissSelectors).toEqual(['button:has-text("Accept")']);
|
||||
expect(args.removeSelectors).toEqual(['.newsletter-modal']);
|
||||
expect(args.headers).toEqual({ Authorization: 'Bearer token' });
|
||||
expect(args.connectCdp).toBe('http://localhost:9222');
|
||||
expect(args.storageState).toBe('.local/auth/linear.json');
|
||||
expect(args.hideScrollbar).toBe(true);
|
||||
expect(args.scrollWarmup).toBe(true);
|
||||
expect(args.quality).toBe(92);
|
||||
});
|
||||
|
||||
it('resolves a single theme from theme.json metadata into the official homepage asset path', () => {
|
||||
const { workspaceRoot, appRoot } = makeFixture();
|
||||
const args = parseCaptureArgs(['node', 'capture-theme-homepage.mjs', '--theme', 'linear']);
|
||||
|
||||
const jobs = resolveCaptureJobs(args, { appRoot, workspaceRoot });
|
||||
|
||||
expect(jobs).toHaveLength(1);
|
||||
expect(jobs[0].theme).toBe('linear');
|
||||
expect(jobs[0].url).toBe('https://linear.app/');
|
||||
expect(jobs[0].outputPath).toBe(path.join(appRoot, 'src/themes/linear/assets/official-homepage.webp'));
|
||||
expect(jobs[0].reportPath).toBe(path.join(workspaceRoot, '.local/theme-captures/linear/meta.json'));
|
||||
expect(jobs[0].viewport).toEqual({ width: 1440, height: 1200 });
|
||||
expect(jobs[0].waitUntil).toBe('load');
|
||||
expect(jobs[0].hideScrollbar).toBe(true);
|
||||
expect(jobs[0].scrollWarmup).toBe(true);
|
||||
expect(jobs[0]).not.toHaveProperty('dryRun');
|
||||
expect(jobs[0]).not.toHaveProperty('help');
|
||||
});
|
||||
|
||||
it('merges config defaults, per-theme settings, and CLI overrides for batch capture', () => {
|
||||
const { workspaceRoot, appRoot } = makeFixture();
|
||||
const configPath = path.join(workspaceRoot, 'theme-capture.json');
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify({
|
||||
defaults: {
|
||||
viewport: '1920x1080',
|
||||
waitForSelectors: ['main'],
|
||||
removeSelectors: ['.chat-widget'],
|
||||
headers: { 'X-Capture': 'theme' },
|
||||
},
|
||||
themes: {
|
||||
linear: {
|
||||
url: 'https://linear.app/custom',
|
||||
dismissSelectors: ['button:has-text("Accept all")'],
|
||||
waitAfterLoadMs: 2500,
|
||||
},
|
||||
stripe: 'https://stripe.com',
|
||||
},
|
||||
}, null, 2),
|
||||
);
|
||||
|
||||
const args = parseCaptureArgs([
|
||||
'node',
|
||||
'capture-theme-homepage.mjs',
|
||||
'--config',
|
||||
configPath,
|
||||
'--theme',
|
||||
'linear',
|
||||
'--viewport',
|
||||
'1366x900',
|
||||
'--header',
|
||||
'Authorization=Bearer local',
|
||||
]);
|
||||
|
||||
const jobs = resolveCaptureJobs(args, { appRoot, workspaceRoot });
|
||||
|
||||
expect(jobs).toHaveLength(1);
|
||||
expect(jobs[0]).toMatchObject({
|
||||
theme: 'linear',
|
||||
url: 'https://linear.app/custom',
|
||||
viewport: { width: 1366, height: 900 },
|
||||
waitForSelectors: ['main'],
|
||||
removeSelectors: ['.chat-widget'],
|
||||
dismissSelectors: ['button:has-text("Accept all")'],
|
||||
waitAfterLoadMs: 2500,
|
||||
headers: {
|
||||
'X-Capture': 'theme',
|
||||
Authorization: 'Bearer local',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('expands all config themes when --all is requested', () => {
|
||||
const { workspaceRoot, appRoot } = makeFixture();
|
||||
const configPath = path.join(workspaceRoot, 'theme-capture.json');
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify({
|
||||
themes: [
|
||||
{ theme: 'linear', url: 'https://linear.app' },
|
||||
{ theme: 'stripe', url: 'https://stripe.com' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const args = parseCaptureArgs(['node', 'capture-theme-homepage.mjs', '--config', configPath, '--all']);
|
||||
|
||||
expect(resolveCaptureJobs(args, { appRoot, workspaceRoot }).map((job) => job.theme)).toEqual([
|
||||
'linear',
|
||||
'stripe',
|
||||
]);
|
||||
});
|
||||
|
||||
it('finds the workspace root and rejects invalid viewport strings', () => {
|
||||
const { workspaceRoot, appRoot } = makeFixture();
|
||||
|
||||
expect(findWorkspaceRoot(appRoot)).toBe(workspaceRoot);
|
||||
expect(() => normalizeViewport('wide')).toThrow('Invalid viewport');
|
||||
});
|
||||
|
||||
it('builds launch options with an explicit or discovered Chrome executable fallback', () => {
|
||||
expect(resolveChromiumLaunchOptions({
|
||||
headless: true,
|
||||
browserExecutable: '/tmp/Test Chrome',
|
||||
}).executablePath).toBe('/tmp/Test Chrome');
|
||||
|
||||
const discovered = resolveChromiumLaunchOptions(
|
||||
{ headless: false },
|
||||
{
|
||||
candidatePaths: ['/missing/chrome', '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'],
|
||||
exists: (candidate) => candidate.includes('Google Chrome'),
|
||||
},
|
||||
);
|
||||
|
||||
expect(discovered.headless).toBe(false);
|
||||
expect(discovered.executablePath).toBe('/Applications/Google Chrome.app/Contents/MacOS/Google Chrome');
|
||||
expect(discovered.args).toContain('--disable-blink-features=AutomationControlled');
|
||||
});
|
||||
|
||||
it('captures webp outputs through a temporary png screenshot plan', () => {
|
||||
const plan = resolveScreenshotPlan({
|
||||
theme: 'linear',
|
||||
format: 'webp',
|
||||
outputPath: '/tmp/linear/official-homepage.webp',
|
||||
reportDir: '/tmp/.local/theme-captures/linear',
|
||||
});
|
||||
|
||||
expect(plan).toEqual({
|
||||
playwrightType: 'png',
|
||||
screenshotPath: '/tmp/.local/theme-captures/linear/official-homepage-source.png',
|
||||
outputPath: '/tmp/linear/official-homepage.webp',
|
||||
needsConversion: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('clips very tall webp screenshots to the encoder-safe height', () => {
|
||||
expect(resolveScreenshotBox({
|
||||
format: 'webp',
|
||||
viewport: { width: 1440, height: 1200 },
|
||||
maxScreenshotHeight: 16383,
|
||||
}, {
|
||||
scrollHeight: 24000,
|
||||
})).toEqual({
|
||||
fullPage: false,
|
||||
viewport: { width: 1440, height: 16383 },
|
||||
clipped: true,
|
||||
sourceHeight: 24000,
|
||||
outputHeight: 16383,
|
||||
});
|
||||
|
||||
expect(resolveScreenshotBox({
|
||||
format: 'webp',
|
||||
viewport: { width: 1440, height: 1200 },
|
||||
maxScreenshotHeight: 16383,
|
||||
}, {
|
||||
scrollHeight: 8000,
|
||||
})).toEqual({
|
||||
fullPage: true,
|
||||
clipped: false,
|
||||
sourceHeight: 8000,
|
||||
outputHeight: 8000,
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes horizontally overflowing screenshots back to the viewport width', () => {
|
||||
expect(resolveWidthNormalization({
|
||||
viewport: { width: 1440, height: 1200 },
|
||||
}, {
|
||||
width: 3870,
|
||||
height: 6517,
|
||||
})).toEqual({
|
||||
needed: true,
|
||||
width: 1440,
|
||||
height: 6517,
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
|
||||
expect(resolveWidthNormalization({
|
||||
viewport: { width: 1440, height: 1200 },
|
||||
}, {
|
||||
width: 1440,
|
||||
height: 6517,
|
||||
})).toEqual({
|
||||
needed: false,
|
||||
width: 1440,
|
||||
height: 6517,
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
824
scripts/capture-theme-source.mjs
Normal file
824
scripts/capture-theme-source.mjs
Normal 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;
|
||||
});
|
||||
}
|
||||
105
scripts/capture-theme-source.test.mjs
Normal file
105
scripts/capture-theme-source.test.mjs
Normal 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
878
scripts/check-app-ready.mjs
Normal file
@@ -0,0 +1,878 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* =====================================================
|
||||
* CLI: check-app-ready
|
||||
*
|
||||
* 功能:
|
||||
* - AI 调用,检测 Vite dev server 和页面状态
|
||||
* - 不依赖页面注入
|
||||
* - 捕获已存在和后续构建/热更新错误
|
||||
* - 页面可访问即 READY,出现错误即 ERROR
|
||||
* - 超时返回 TIMEOUT
|
||||
* - 默认包含构建校验,可通过 --skip-build 跳过
|
||||
*
|
||||
* 使用:
|
||||
* node scripts/check-app-ready.mjs [页面路径]
|
||||
* 例如:node scripts/check-app-ready.mjs /prototypes/ref-app-home
|
||||
* node scripts/check-app-ready.mjs /prototypes/home
|
||||
*
|
||||
* 跳过构建校验:
|
||||
* node scripts/check-app-ready.mjs --skip-build /prototypes/ref-app-home
|
||||
*
|
||||
* 输出(JSON):
|
||||
* {
|
||||
* status: "READY" | "ERROR" | "TIMEOUT",
|
||||
* phase: "server|build|page|done",
|
||||
* message: "...",
|
||||
* url: "http://localhost:51720/prototypes/ref-app-home",
|
||||
* errors: [...],
|
||||
* logs: [...],
|
||||
* buildCheck?: { status: "SUCCESS" | "FAILED" | "SKIPPED", errors: [...], logs: [...] }
|
||||
* lintCheck?: { status: "SUCCESS" | "FAILED" | "SKIPPED", errors: [...], logs: [...] }
|
||||
* typeCheck?: { status: "SUCCESS" | "FAILED" | "SKIPPED", errors: [...], logs: [...] }
|
||||
* checks?: [{ name: "lint|typecheck|build", status: "...", message: "...", errors: [...] }]
|
||||
* homeUrl?: "http://localhost:51720"
|
||||
* targetUrl?: "http://localhost:51720/prototypes/ref-app-home"
|
||||
* targetPath?: "http://localhost:51720/prototypes/ref-app-home/index.html"
|
||||
* }
|
||||
* =====================================================
|
||||
*/
|
||||
|
||||
import { spawn } from 'node:child_process'
|
||||
import { setTimeout as sleep } from 'node:timers/promises'
|
||||
import process from 'node:process'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { decodeOutput, getPreferredNpmCommand, getPreferredNpxCommand } from './utils/command-runtime.mjs'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const APP_ROOT = path.resolve(__dirname, '..')
|
||||
|
||||
/* ================= 配置 ================= */
|
||||
// 解析命令行参数
|
||||
const args = process.argv.slice(2);
|
||||
const skipBuild = args.includes('--skip-build');
|
||||
const pagePath = args.find(arg => !arg.startsWith('--')) || '/';
|
||||
|
||||
const CONFIG = {
|
||||
devCommand: ['run', 'dev'], // 启动 Vite 的命令参数
|
||||
devServerInfoPath: path.resolve(__dirname, '../.axhub/make/.dev-server-info.json'), // 开发服务器信息文件
|
||||
pagePath, // 目标页面路径(从命令行参数获取)
|
||||
pollIntervalMs: 500, // 页面轮询间隔
|
||||
stableCheckMs: 1000, // 错误稳定判断时间
|
||||
timeoutMs: 30_000, // 总超时
|
||||
skipBuild // 是否跳过构建校验
|
||||
}
|
||||
|
||||
/* ================= 工具函数 ================= */
|
||||
function jsonExit(payload, code = 0) {
|
||||
process.stdout.write(JSON.stringify(payload, null, 2))
|
||||
process.exit(code)
|
||||
}
|
||||
|
||||
/**
|
||||
* 尝试通过 HTTP 请求获取页面内容,检查是否有错误信息
|
||||
*/
|
||||
async function checkPageForErrors(url) {
|
||||
try {
|
||||
const res = await fetch(url, { method: 'GET' })
|
||||
const text = await res.text()
|
||||
|
||||
// Only treat the HTML as an error page when Vite's overlay is present.
|
||||
// The app template includes global error handlers with object literals like
|
||||
// `{ error: event.error }`, which would otherwise cause false positives.
|
||||
const hasViteOverlay =
|
||||
text.includes('vite-error-overlay') ||
|
||||
text.includes('__vite_error_overlay__') ||
|
||||
/\[plugin:vite:/i.test(text) ||
|
||||
/Transform failed/i.test(text)
|
||||
|
||||
if (!hasViteOverlay) return []
|
||||
|
||||
const errorPatterns = [
|
||||
/\bError:\s*([^\n]+)/,
|
||||
/\bSyntaxError:\s*([^\n]+)/,
|
||||
/\bReferenceError:\s*([^\n]+)/,
|
||||
/\[plugin:vite:[^\]]+\]\s*([^\n]+)/i,
|
||||
/Transform failed/i
|
||||
]
|
||||
|
||||
for (const pattern of errorPatterns) {
|
||||
const match = text.match(pattern)
|
||||
if (match) return [match[1] || match[0]]
|
||||
}
|
||||
|
||||
return ['Detected Vite error overlay but could not extract message']
|
||||
} catch (err) {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async function isServerAlive(url) {
|
||||
try {
|
||||
const res = await fetch(url, { method: 'GET' })
|
||||
return res.ok
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取开发服务器信息
|
||||
* 优先从 .axhub/make/.dev-server-info.json 读取实际运行的端口
|
||||
*/
|
||||
function getServerInfo() {
|
||||
try {
|
||||
if (fs.existsSync(CONFIG.devServerInfoPath)) {
|
||||
const info = JSON.parse(fs.readFileSync(CONFIG.devServerInfoPath, 'utf8'))
|
||||
return {
|
||||
port: info.port,
|
||||
host: info.host || 'localhost',
|
||||
localIP: info.localIP || 'localhost'
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logs.push(`Failed to read .axhub/make/.dev-server-info.json: ${err.message}`)
|
||||
}
|
||||
|
||||
// 如果没有端口信息,返回 null 表示需要等待服务器启动
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成服务器首页 URL
|
||||
* 使用 localhost 而不是 0.0.0.0,因为浏览器无法访问 0.0.0.0
|
||||
*/
|
||||
function getHomeUrl(serverInfo) {
|
||||
// 如果 host 是 0.0.0.0,使用 localhost 替代
|
||||
const host = serverInfo.host === '0.0.0.0' ? 'localhost' : serverInfo.host
|
||||
return `http://${host}:${serverInfo.port}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取可访问的 host
|
||||
* 将 0.0.0.0 转换为 localhost,因为浏览器无法直接访问 0.0.0.0
|
||||
*/
|
||||
function getAccessibleHost(serverInfo) {
|
||||
return serverInfo.host === '0.0.0.0' ? 'localhost' : serverInfo.host
|
||||
}
|
||||
|
||||
function getTargetUrl(serverInfo, targetPath) {
|
||||
const host = getAccessibleHost(serverInfo)
|
||||
return `http://${host}:${serverInfo.port}${targetPath}`
|
||||
}
|
||||
|
||||
function getEntryHtmlPath(targetPath) {
|
||||
const normalized = targetPath.startsWith('/') ? targetPath : `/${targetPath}`
|
||||
if (normalized.endsWith('.html')) return normalized
|
||||
if (normalized.endsWith('/')) return `${normalized}index.html`
|
||||
return `${normalized}/index.html`
|
||||
}
|
||||
|
||||
/* ================= 全局状态 ================= */
|
||||
let logs = []
|
||||
let errors = []
|
||||
let lastErrorTime = 0
|
||||
let errorCache = new Set() // 用于去重错误信息
|
||||
|
||||
/* ================= 阶段 1:启动或 attach Vite ================= */
|
||||
function startOrAttachVite() {
|
||||
logs.push('Checking Vite server...')
|
||||
const npmCommand = getPreferredNpmCommand()
|
||||
const child = spawn(npmCommand, CONFIG.devCommand, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
cwd: APP_ROOT,
|
||||
shell: false,
|
||||
})
|
||||
|
||||
child.stdout.on('data', (data) => {
|
||||
const text = decodeOutput(data).trim()
|
||||
if (text) logs.push(text)
|
||||
|
||||
// 检测构建错误
|
||||
if (/error/i.test(text) || /failed to compile/i.test(text)) {
|
||||
errors.push(text)
|
||||
lastErrorTime = Date.now()
|
||||
}
|
||||
})
|
||||
|
||||
child.stderr.on('data', (data) => {
|
||||
const text = decodeOutput(data).trim()
|
||||
if (text) {
|
||||
// 过滤掉一些正常的警告信息
|
||||
if (!/deprecated|experimental/i.test(text)) {
|
||||
errors.push(text)
|
||||
lastErrorTime = Date.now()
|
||||
}
|
||||
logs.push(text)
|
||||
}
|
||||
})
|
||||
|
||||
child.on('error', (err) => {
|
||||
errors.push(`Process error: ${err.message}`)
|
||||
lastErrorTime = Date.now()
|
||||
})
|
||||
|
||||
return child
|
||||
}
|
||||
|
||||
/* ================= 阶段 2:轮询页面可访问性 ================= */
|
||||
async function waitForPage(url) {
|
||||
const start = Date.now()
|
||||
let lastCheckTime = 0
|
||||
|
||||
while (Date.now() - start < CONFIG.timeoutMs) {
|
||||
const now = Date.now()
|
||||
|
||||
// 每隔一段时间尝试获取错误信息(即使页面不可访问)
|
||||
if (now - lastCheckTime > 2000) {
|
||||
const pageErrors = await checkPageForErrors(url)
|
||||
if (pageErrors.length > 0) {
|
||||
// 去重:只添加未见过的错误
|
||||
pageErrors.forEach(err => {
|
||||
const errorKey = err.substring(0, 200) // 使用前200个字符作为唯一标识
|
||||
if (!errorCache.has(errorKey)) {
|
||||
errorCache.add(errorKey)
|
||||
errors.push(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
lastCheckTime = now
|
||||
}
|
||||
|
||||
if (await isServerAlive(url)) return true
|
||||
await sleep(CONFIG.pollIntervalMs)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/* ================= 阶段 3:等待稳定状态 ================= */
|
||||
async function waitForStable(pageUrl) {
|
||||
const startTime = Date.now()
|
||||
|
||||
while (Date.now() - startTime < CONFIG.timeoutMs) {
|
||||
const now = Date.now()
|
||||
|
||||
// 页面可访问
|
||||
const pageOk = await isServerAlive(pageUrl)
|
||||
|
||||
// 如果页面可访问,尝试检查页面内容中的错误
|
||||
if (pageOk) {
|
||||
const pageErrors = await checkPageForErrors(pageUrl)
|
||||
if (pageErrors.length > 0) {
|
||||
return {
|
||||
status: 'ERROR',
|
||||
phase: 'build',
|
||||
message: 'Detected error in page content',
|
||||
url: pageUrl,
|
||||
errors: pageErrors,
|
||||
logs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 错误稳定:最近 stableCheckMs 内没有新的错误
|
||||
const stable = (now - lastErrorTime) > CONFIG.stableCheckMs
|
||||
|
||||
if (!pageOk) {
|
||||
// 页面不可访问,继续轮询
|
||||
await sleep(CONFIG.pollIntervalMs)
|
||||
continue
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return {
|
||||
status: 'ERROR',
|
||||
phase: 'build',
|
||||
message: 'Detected Vite build/runtime error',
|
||||
url: pageUrl,
|
||||
errors,
|
||||
logs
|
||||
}
|
||||
}
|
||||
|
||||
if (pageOk && stable) {
|
||||
return {
|
||||
status: 'READY',
|
||||
phase: 'done',
|
||||
message: 'Page ready and stable',
|
||||
url: pageUrl,
|
||||
errors: [],
|
||||
logs
|
||||
}
|
||||
}
|
||||
|
||||
await sleep(CONFIG.pollIntervalMs)
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'TIMEOUT',
|
||||
phase: 'server',
|
||||
message: 'Timeout waiting for page/stable state',
|
||||
url: pageUrl,
|
||||
errors,
|
||||
logs
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 为结果添加服务器首页信息
|
||||
*/
|
||||
function addUrls(result, serverInfo) {
|
||||
if (!serverInfo) {
|
||||
return {
|
||||
...result,
|
||||
homeUrl: null,
|
||||
targetUrl: null,
|
||||
targetPath: null
|
||||
}
|
||||
}
|
||||
|
||||
const entryHtmlPath = getEntryHtmlPath(CONFIG.pagePath)
|
||||
|
||||
return {
|
||||
...result,
|
||||
homeUrl: getHomeUrl(serverInfo),
|
||||
targetUrl: getTargetUrl(serverInfo, CONFIG.pagePath),
|
||||
targetPath: getTargetUrl(serverInfo, entryHtmlPath)
|
||||
}
|
||||
}
|
||||
|
||||
function readPackageJson() {
|
||||
const pkgPath = path.resolve(APP_ROOT, 'package.json')
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(pkgPath, 'utf8'))
|
||||
} catch (err) {
|
||||
logs.push(`Failed to read package.json: ${err.message}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function getScriptCommand(pkgJson, scriptName) {
|
||||
if (!pkgJson || !pkgJson.scripts) return null
|
||||
return pkgJson.scripts[scriptName] || null
|
||||
}
|
||||
|
||||
function hasEslintConfig(pkgJson) {
|
||||
if (pkgJson && pkgJson.eslintConfig) return true
|
||||
const configFiles = [
|
||||
'.eslintrc',
|
||||
'.eslintrc.js',
|
||||
'.eslintrc.cjs',
|
||||
'.eslintrc.json',
|
||||
'.eslintrc.yaml',
|
||||
'.eslintrc.yml',
|
||||
'eslint.config.js',
|
||||
'eslint.config.cjs',
|
||||
'eslint.config.mjs'
|
||||
]
|
||||
return configFiles.some((file) => fs.existsSync(path.resolve(APP_ROOT, file)))
|
||||
}
|
||||
|
||||
function hasTsConfig() {
|
||||
return fs.existsSync(path.resolve(APP_ROOT, 'tsconfig.json'))
|
||||
}
|
||||
|
||||
function toCheckItem(name, result) {
|
||||
if (!result) return null
|
||||
return {
|
||||
name,
|
||||
status: result.status,
|
||||
message: result.message,
|
||||
errors: result.errors || []
|
||||
}
|
||||
}
|
||||
|
||||
function buildChecksSummary({ lintResult, typeCheckResult, buildResult }) {
|
||||
return [
|
||||
toCheckItem('lint', lintResult),
|
||||
toCheckItem('typecheck', typeCheckResult),
|
||||
toCheckItem('build', buildResult)
|
||||
].filter(Boolean)
|
||||
}
|
||||
|
||||
async function runCommandCheck({ label, command, args = [], env = {}, logTag }) {
|
||||
logs.push(`${label} check started`)
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const checkErrors = []
|
||||
const checkLogs = []
|
||||
const resolvedCommand = command === 'npm'
|
||||
? getPreferredNpmCommand()
|
||||
: command === 'npx'
|
||||
? getPreferredNpxCommand()
|
||||
: command
|
||||
|
||||
const proc = spawn(resolvedCommand, args, {
|
||||
cwd: APP_ROOT,
|
||||
env: { ...process.env, ...env },
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
|
||||
const appendLog = (line, isError = false) => {
|
||||
if (!line) return
|
||||
checkLogs.push(line)
|
||||
logs.push(`[${logTag}] ${line}`)
|
||||
if (isError && !/deprecated|experimental/i.test(line)) {
|
||||
checkErrors.push(line)
|
||||
}
|
||||
}
|
||||
|
||||
proc.stdout.on('data', (data) => {
|
||||
appendLog(decodeOutput(data).trim(), false)
|
||||
})
|
||||
|
||||
proc.stderr.on('data', (data) => {
|
||||
appendLog(decodeOutput(data).trim(), true)
|
||||
})
|
||||
|
||||
proc.on('close', (code) => {
|
||||
if (code === 0 && checkErrors.length === 0) {
|
||||
resolve({
|
||||
status: 'SUCCESS',
|
||||
message: `${label} completed successfully`,
|
||||
errors: [],
|
||||
logs: checkLogs
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
resolve({
|
||||
status: 'FAILED',
|
||||
message: `${label} failed (exit code: ${code})`,
|
||||
errors: checkErrors.length > 0 ? checkErrors : [`${label} exited with code ${code}`],
|
||||
logs: checkLogs
|
||||
})
|
||||
})
|
||||
|
||||
proc.on('error', (err) => {
|
||||
logs.push(`${label} process error: ${err.message}`)
|
||||
resolve({
|
||||
status: 'FAILED',
|
||||
message: `${label} process error: ${err.message}`,
|
||||
errors: [err.message],
|
||||
logs: checkLogs
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function runLintCheck() {
|
||||
const pkgJson = readPackageJson()
|
||||
const lintScript = getScriptCommand(pkgJson, 'lint')
|
||||
|
||||
if (lintScript) {
|
||||
return runCommandCheck({
|
||||
label: 'Lint',
|
||||
command: 'npm',
|
||||
args: ['run', 'lint'],
|
||||
logTag: 'LINT'
|
||||
})
|
||||
}
|
||||
|
||||
if (!hasEslintConfig(pkgJson)) {
|
||||
return {
|
||||
status: 'SKIPPED',
|
||||
message: 'Lint skipped: no eslint config or lint script found',
|
||||
errors: [],
|
||||
logs: []
|
||||
}
|
||||
}
|
||||
|
||||
return runCommandCheck({
|
||||
label: 'Lint',
|
||||
command: 'npx',
|
||||
args: ['eslint', '.'],
|
||||
logTag: 'LINT'
|
||||
})
|
||||
}
|
||||
|
||||
async function runTypeCheck() {
|
||||
const pkgJson = readPackageJson()
|
||||
const typecheckScript = getScriptCommand(pkgJson, 'typecheck')
|
||||
|
||||
if (typecheckScript) {
|
||||
return runCommandCheck({
|
||||
label: 'Typecheck',
|
||||
command: 'npm',
|
||||
args: ['run', 'typecheck'],
|
||||
logTag: 'TYPECHECK'
|
||||
})
|
||||
}
|
||||
|
||||
if (!hasTsConfig()) {
|
||||
return {
|
||||
status: 'SKIPPED',
|
||||
message: 'Typecheck skipped: no tsconfig.json or typecheck script found',
|
||||
errors: [],
|
||||
logs: []
|
||||
}
|
||||
}
|
||||
|
||||
return runCommandCheck({
|
||||
label: 'Typecheck',
|
||||
command: 'npx',
|
||||
args: ['tsc', '--noEmit'],
|
||||
logTag: 'TYPECHECK'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描并更新 .axhub/make/entries.json
|
||||
* 确保新创建的目录被包含在入口列表中
|
||||
*/
|
||||
async function scanEntries() {
|
||||
logs.push('Scanning entries...')
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const scanProcess = spawn(process.execPath, ['scripts/scan-entries.js'], {
|
||||
cwd: APP_ROOT,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
|
||||
scanProcess.stdout.on('data', (data) => {
|
||||
const text = decodeOutput(data).trim()
|
||||
if (text) logs.push(`[SCAN] ${text}`)
|
||||
})
|
||||
|
||||
scanProcess.stderr.on('data', (data) => {
|
||||
const text = decodeOutput(data).trim()
|
||||
if (text) logs.push(`[SCAN ERROR] ${text}`)
|
||||
})
|
||||
|
||||
scanProcess.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
logs.push('Entry scanning completed')
|
||||
resolve({ success: true })
|
||||
} else {
|
||||
logs.push(`Entry scanning failed with exit code ${code}`)
|
||||
resolve({ success: false })
|
||||
}
|
||||
})
|
||||
|
||||
scanProcess.on('error', (err) => {
|
||||
logs.push(`Entry scanning error: ${err.message}`)
|
||||
resolve({ success: false })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行独立构建校验
|
||||
* 针对指定的入口 key 执行单独构建,不是全量构建
|
||||
*/
|
||||
async function runBuildCheck(entryKey) {
|
||||
const originalEntryKey = String(entryKey ?? '').trim()
|
||||
logs.push(`Starting build check for entry: ${originalEntryKey || '(auto)'}`)
|
||||
|
||||
// 先扫描入口,确保 .axhub/make/entries.json 是最新的
|
||||
const scanResult = await scanEntries()
|
||||
if (!scanResult.success) {
|
||||
return {
|
||||
status: 'FAILED',
|
||||
message: 'Failed to scan entries before build',
|
||||
errors: ['Entry scanning failed'],
|
||||
logs: []
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedEntryKey = originalEntryKey || resolveDefaultEntryKey()
|
||||
if (!resolvedEntryKey) {
|
||||
logs.push('Build check skipped: no entry key resolved')
|
||||
return {
|
||||
status: 'SKIPPED',
|
||||
message: 'Build check skipped: no entry key resolved',
|
||||
errors: [],
|
||||
logs: []
|
||||
}
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const buildErrors = []
|
||||
const buildLogs = []
|
||||
const npxCommand = getPreferredNpxCommand()
|
||||
|
||||
// 使用 ENTRY_KEY 环境变量触发单独构建
|
||||
const buildProcess = spawn(npxCommand, ['vite', 'build'], {
|
||||
cwd: APP_ROOT,
|
||||
env: { ...process.env, ENTRY_KEY: resolvedEntryKey },
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
|
||||
buildProcess.stdout.on('data', (data) => {
|
||||
const text = decodeOutput(data).trim()
|
||||
if (text) {
|
||||
buildLogs.push(text)
|
||||
logs.push(`[BUILD] ${text}`)
|
||||
}
|
||||
})
|
||||
|
||||
buildProcess.stderr.on('data', (data) => {
|
||||
const text = decodeOutput(data).trim()
|
||||
if (text) {
|
||||
buildLogs.push(text)
|
||||
logs.push(`[BUILD ERROR] ${text}`)
|
||||
// 捕获构建错误
|
||||
if (/error|failed/i.test(text) && !/deprecated|experimental/i.test(text)) {
|
||||
buildErrors.push(text)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
buildProcess.on('close', (code) => {
|
||||
if (code === 0 && buildErrors.length === 0) {
|
||||
logs.push(`Build check completed successfully for ${resolvedEntryKey}`)
|
||||
resolve({
|
||||
status: 'SUCCESS',
|
||||
message: `Build completed successfully for ${resolvedEntryKey}`,
|
||||
errors: [],
|
||||
logs: buildLogs
|
||||
})
|
||||
} else {
|
||||
logs.push(`Build check failed for ${resolvedEntryKey} with exit code ${code}`)
|
||||
resolve({
|
||||
status: 'FAILED',
|
||||
message: `Build failed for ${resolvedEntryKey} (exit code: ${code})`,
|
||||
errors: buildErrors.length > 0 ? buildErrors : [`Build process exited with code ${code}`],
|
||||
logs: buildLogs
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
buildProcess.on('error', (err) => {
|
||||
logs.push(`Build process error: ${err.message}`)
|
||||
resolve({
|
||||
status: 'FAILED',
|
||||
message: `Build process error: ${err.message}`,
|
||||
errors: [err.message],
|
||||
logs: buildLogs
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function resolveDefaultEntryKey() {
|
||||
try {
|
||||
const entriesPath = path.resolve(APP_ROOT, '.axhub/make/entries.json')
|
||||
if (!fs.existsSync(entriesPath)) return null
|
||||
const raw = JSON.parse(fs.readFileSync(entriesPath, 'utf8'))
|
||||
const jsEntries = raw && typeof raw === 'object' ? (raw.js || {}) : {}
|
||||
const keys = Object.keys(jsEntries || {}).filter(Boolean).sort((a, b) => a.localeCompare(b))
|
||||
if (keys.length === 0) return null
|
||||
|
||||
const pickFromPrefix = (prefix) => keys.find((k) => k.startsWith(prefix))
|
||||
return (
|
||||
pickFromPrefix('prototypes/') ||
|
||||
pickFromPrefix('themes/') ||
|
||||
keys[0] ||
|
||||
null
|
||||
)
|
||||
} catch (err) {
|
||||
logs.push(`Failed to resolve default entry key: ${err.message}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从页面路径推断入口 key
|
||||
* 例如:/prototypes/ref-app-home -> prototypes/ref-app-home
|
||||
*/
|
||||
function getEntryKeyFromPath(pagePath) {
|
||||
// 移除开头的斜杠
|
||||
return pagePath.replace(/^\//, '')
|
||||
}
|
||||
|
||||
/* ================= 主流程 ================= */
|
||||
async function main() {
|
||||
try {
|
||||
// 获取服务器信息
|
||||
const serverInfo = getServerInfo()
|
||||
|
||||
// 如果没有端口信息,等待服务器启动
|
||||
if (!serverInfo) {
|
||||
logs.push('Waiting for server to start...')
|
||||
// 启动服务器并等待
|
||||
const viteProcess = startOrAttachVite()
|
||||
|
||||
// 等待 .axhub/make/.dev-server-info.json 文件生成
|
||||
const maxWait = 10000 // 10秒
|
||||
const startTime = Date.now()
|
||||
let newServerInfo = null
|
||||
|
||||
while (Date.now() - startTime < maxWait) {
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
newServerInfo = getServerInfo()
|
||||
if (newServerInfo) break
|
||||
}
|
||||
|
||||
if (!newServerInfo) {
|
||||
return jsonExit(addUrls({
|
||||
status: 'ERROR',
|
||||
phase: 'server',
|
||||
message: 'Server failed to start - no port information available',
|
||||
url: CONFIG.pagePath,
|
||||
errors: ['Server did not write port information within timeout'],
|
||||
logs
|
||||
}, null), 1)
|
||||
}
|
||||
|
||||
// 使用新获取的服务器信息
|
||||
const accessibleHost = getAccessibleHost(newServerInfo)
|
||||
const pageUrl = `http://${accessibleHost}:${newServerInfo.port}${CONFIG.pagePath}`
|
||||
|
||||
logs.push(`Target URL: ${pageUrl}`)
|
||||
logs.push(`Server info: port=${newServerInfo.port}, host=${newServerInfo.host}`)
|
||||
|
||||
// 继续后续流程...
|
||||
await continueWithServerInfo(newServerInfo, pageUrl, viteProcess)
|
||||
} else {
|
||||
const accessibleHost = getAccessibleHost(serverInfo)
|
||||
const pageUrl = `http://${accessibleHost}:${serverInfo.port}${CONFIG.pagePath}`
|
||||
|
||||
logs.push(`Target URL: ${pageUrl}`)
|
||||
logs.push(`Server info: port=${serverInfo.port}, host=${serverInfo.host}`)
|
||||
|
||||
// 继续后续流程...
|
||||
await continueWithServerInfo(serverInfo, pageUrl, null)
|
||||
}
|
||||
} catch (err) {
|
||||
const serverInfo = getServerInfo()
|
||||
jsonExit(addUrls({
|
||||
status: 'ERROR',
|
||||
phase: 'server',
|
||||
message: err.message,
|
||||
url: CONFIG.pagePath,
|
||||
errors: [String(err)],
|
||||
logs
|
||||
}, serverInfo), 1)
|
||||
}
|
||||
}
|
||||
|
||||
async function continueWithServerInfo(serverInfo, pageUrl, viteProcess) {
|
||||
try {
|
||||
// 步骤 1: 执行 lint 检查
|
||||
const lintResult = await runLintCheck()
|
||||
if (lintResult.status === 'FAILED') {
|
||||
return jsonExit(addUrls({
|
||||
status: 'ERROR',
|
||||
phase: 'lint',
|
||||
message: lintResult.message,
|
||||
url: pageUrl,
|
||||
errors: lintResult.errors,
|
||||
logs,
|
||||
lintCheck: lintResult,
|
||||
checks: buildChecksSummary({ lintResult })
|
||||
}, serverInfo), 1)
|
||||
}
|
||||
|
||||
// 步骤 2: 执行 typecheck 检查
|
||||
const typeCheckResult = await runTypeCheck()
|
||||
if (typeCheckResult.status === 'FAILED') {
|
||||
return jsonExit(addUrls({
|
||||
status: 'ERROR',
|
||||
phase: 'typecheck',
|
||||
message: typeCheckResult.message,
|
||||
url: pageUrl,
|
||||
errors: typeCheckResult.errors,
|
||||
logs,
|
||||
lintCheck: lintResult,
|
||||
typeCheck: typeCheckResult,
|
||||
checks: buildChecksSummary({ lintResult, typeCheckResult })
|
||||
}, serverInfo), 1)
|
||||
}
|
||||
|
||||
// 步骤 3: 执行构建校验(除非指定 --skip-build)
|
||||
let buildResult = null
|
||||
if (!CONFIG.skipBuild) {
|
||||
const entryKey = getEntryKeyFromPath(CONFIG.pagePath)
|
||||
logs.push(`Build check enabled for entry: ${entryKey}`)
|
||||
buildResult = await runBuildCheck(entryKey)
|
||||
|
||||
// 如果构建失败,直接返回错误
|
||||
if (buildResult.status === 'FAILED') {
|
||||
return jsonExit(addUrls({
|
||||
status: 'ERROR',
|
||||
phase: 'build',
|
||||
message: buildResult.message,
|
||||
url: pageUrl,
|
||||
errors: buildResult.errors,
|
||||
logs,
|
||||
buildCheck: buildResult,
|
||||
lintCheck: lintResult,
|
||||
typeCheck: typeCheckResult,
|
||||
checks: buildChecksSummary({ lintResult, typeCheckResult, buildResult })
|
||||
}, serverInfo), 1)
|
||||
}
|
||||
} else {
|
||||
logs.push('Build check skipped (--skip-build flag)')
|
||||
buildResult = {
|
||||
status: 'SKIPPED',
|
||||
message: 'Build check skipped (--skip-build flag)',
|
||||
errors: [],
|
||||
logs: []
|
||||
}
|
||||
}
|
||||
|
||||
// 步骤 4: 开发服务器校验
|
||||
const accessibleHost = getAccessibleHost(serverInfo)
|
||||
|
||||
// 检查服务器是否已经在运行
|
||||
const serverAlreadyRunning = await isServerAlive(`http://${accessibleHost}:${serverInfo.port}`)
|
||||
|
||||
let viteChild = viteProcess
|
||||
if (!serverAlreadyRunning && !viteChild) {
|
||||
logs.push('Server not running, starting Vite...')
|
||||
viteChild = startOrAttachVite()
|
||||
} else {
|
||||
logs.push('Server already running, skipping start')
|
||||
}
|
||||
|
||||
// 等待页面可访问
|
||||
const pageReachable = await waitForPage(pageUrl)
|
||||
if (!pageReachable) {
|
||||
if (viteChild) viteChild.kill()
|
||||
return jsonExit(addUrls({
|
||||
status: 'TIMEOUT',
|
||||
phase: 'page',
|
||||
message: 'Page never became reachable',
|
||||
url: pageUrl,
|
||||
errors,
|
||||
logs,
|
||||
buildCheck: buildResult,
|
||||
lintCheck: lintResult,
|
||||
typeCheck: typeCheckResult,
|
||||
checks: buildChecksSummary({ lintResult, typeCheckResult, buildResult })
|
||||
}, serverInfo), 1)
|
||||
}
|
||||
|
||||
// 等待稳定状态
|
||||
const result = await waitForStable(pageUrl)
|
||||
|
||||
// 清理进程
|
||||
if (viteChild) viteChild.kill()
|
||||
|
||||
// 添加构建结果到最终输出
|
||||
const finalResult = {
|
||||
...result,
|
||||
buildCheck: buildResult,
|
||||
lintCheck: lintResult,
|
||||
typeCheck: typeCheckResult,
|
||||
checks: buildChecksSummary({ lintResult, typeCheckResult, buildResult })
|
||||
}
|
||||
|
||||
jsonExit(addUrls(finalResult, serverInfo), result.status === 'READY' ? 0 : 1)
|
||||
} catch (err) {
|
||||
jsonExit(addUrls({
|
||||
status: 'ERROR',
|
||||
phase: 'server',
|
||||
message: err.message,
|
||||
url: CONFIG.pagePath,
|
||||
errors: [String(err)],
|
||||
logs
|
||||
}, serverInfo), 1)
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
873
scripts/chrome-export-converter.mjs
Normal file
873
scripts/chrome-export-converter.mjs
Normal file
@@ -0,0 +1,873 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* =====================================================
|
||||
* Chrome 扩展导出转换器
|
||||
*
|
||||
* 专门处理通过 Chrome 扩展本项目导出的 HTML 文件
|
||||
*
|
||||
* 功能:
|
||||
* 1. 转换 index.html 为 React 组件
|
||||
* 2. 智能处理字体:CDN 保留链接,本地文件复制
|
||||
* 3. 复制静态资源(图片、字体)
|
||||
* 4. 保留完整的 style.css 样式
|
||||
* =====================================================
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { assertValidGeneratedTsx } from './utils/generatedTsxValidator.mjs';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const CONFIG = {
|
||||
projectRoot: path.resolve(__dirname, '..'),
|
||||
pagesDir: path.resolve(__dirname, '../src/prototypes')
|
||||
};
|
||||
|
||||
const JSX_ATTRIBUTE_REPLACEMENTS = [
|
||||
['class', 'className'],
|
||||
['for', 'htmlFor'],
|
||||
['tabindex', 'tabIndex'],
|
||||
['readonly', 'readOnly'],
|
||||
['maxlength', 'maxLength'],
|
||||
['minlength', 'minLength'],
|
||||
['colspan', 'colSpan'],
|
||||
['rowspan', 'rowSpan'],
|
||||
['viewbox', 'viewBox'],
|
||||
['preserveaspectratio', 'preserveAspectRatio'],
|
||||
['clip-path', 'clipPath'],
|
||||
['fill-rule', 'fillRule'],
|
||||
['clip-rule', 'clipRule'],
|
||||
['stroke-width', 'strokeWidth'],
|
||||
['stroke-dasharray', 'strokeDasharray'],
|
||||
['stroke-dashoffset', 'strokeDashoffset'],
|
||||
['stroke-linecap', 'strokeLinecap'],
|
||||
['stroke-linejoin', 'strokeLinejoin'],
|
||||
['stroke-miterlimit', 'strokeMiterlimit'],
|
||||
['stroke-opacity', 'strokeOpacity'],
|
||||
['fill-opacity', 'fillOpacity'],
|
||||
['stop-color', 'stopColor'],
|
||||
['stop-opacity', 'stopOpacity'],
|
||||
['xlink:href', 'xlinkHref'],
|
||||
['xmlns:xlink', 'xmlnsXlink'],
|
||||
];
|
||||
|
||||
function log(message, type = 'info') {
|
||||
const prefix = { info: '✓', warn: '⚠', error: '✗', progress: '⏳' }[type] || 'ℹ';
|
||||
console.log(`${prefix} ${message}`);
|
||||
}
|
||||
|
||||
function ensureDir(dirPath) {
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRelativeDir(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/\\/g, '/')
|
||||
.split('/')
|
||||
.filter(Boolean)
|
||||
.join('/');
|
||||
}
|
||||
|
||||
function isSafeRelativeDir(value) {
|
||||
if (!value) return false;
|
||||
if (value.startsWith('/') || value.startsWith('~')) return false;
|
||||
const segments = value.split('/');
|
||||
if (segments.length === 0) return false;
|
||||
return segments.every((segment) => {
|
||||
if (!segment || segment === '.' || segment === '..') return false;
|
||||
return !/[\\/]/.test(segment);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为 CDN 链接
|
||||
*/
|
||||
function isCDNUrl(url) {
|
||||
return url.startsWith('http://') || url.startsWith('https://') || url.startsWith('//');
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归复制目录
|
||||
*/
|
||||
function copyDirectory(src, dest) {
|
||||
if (!fs.existsSync(src)) return 0;
|
||||
|
||||
ensureDir(dest);
|
||||
const entries = fs.readdirSync(src, { withFileTypes: true });
|
||||
let count = 0;
|
||||
|
||||
for (const entry of entries) {
|
||||
const srcPath = path.join(src, entry.name);
|
||||
const destPath = path.join(dest, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
count += copyDirectory(srcPath, destPath);
|
||||
} else {
|
||||
fs.copyFileSync(srcPath, destPath);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取 head 内容(仅处理外部资源和字体)
|
||||
*/
|
||||
function extractHeadContent(html) {
|
||||
const headMatch = html.match(/<head[^>]*>([\s\S]*?)<\/head>/i);
|
||||
if (!headMatch) return { scripts: [], links: [] };
|
||||
|
||||
const headContent = headMatch[1];
|
||||
const scripts = [];
|
||||
const links = [];
|
||||
|
||||
// 提取 script 标签(排除 Tailwind CDN)
|
||||
const scriptRegex = /<script([^>]*)>([\s\S]*?)<\/script>/gi;
|
||||
let match;
|
||||
while ((match = scriptRegex.exec(headContent)) !== null) {
|
||||
const attrs = match[1];
|
||||
const content = match[2].trim();
|
||||
|
||||
const srcMatch = attrs.match(/src=["']([^"']+)["']/);
|
||||
if (srcMatch) {
|
||||
const src = srcMatch[1].replace(/&/g, '&');
|
||||
// 跳过 Tailwind CDN
|
||||
if (src.includes('tailwindcss.com')) continue;
|
||||
|
||||
scripts.push({
|
||||
src,
|
||||
id: attrs.match(/id=["']([^"']+)["']/)?.[1]
|
||||
});
|
||||
} else if (content) {
|
||||
const id = attrs.match(/id=["']([^"']+)["']/)?.[1];
|
||||
scripts.push({ id, content });
|
||||
}
|
||||
}
|
||||
|
||||
// 提取 link 标签
|
||||
const linkRegex = /<link[^>]*>/gi;
|
||||
while ((match = linkRegex.exec(headContent)) !== null) {
|
||||
const tag = match[0];
|
||||
const href = tag.match(/href=["']([^"']+)["']/)?.[1];
|
||||
if (href) {
|
||||
links.push({
|
||||
href: href.replace(/&/g, '&'),
|
||||
rel: tag.match(/rel=["']([^"']+)["']/)?.[1] || 'stylesheet',
|
||||
crossorigin: tag.includes('crossorigin')
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { scripts, links };
|
||||
}
|
||||
|
||||
/**
|
||||
* 转义文本节点中的花括号
|
||||
* 只处理标签之间的文本内容,不处理属性值
|
||||
*/
|
||||
function escapeTextBraces(html) {
|
||||
const parts = [];
|
||||
let lastIndex = 0;
|
||||
const tagRegex = /<[^>]+>/g;
|
||||
let match;
|
||||
|
||||
while ((match = tagRegex.exec(html)) !== null) {
|
||||
// 提取标签之前的文本
|
||||
const textBefore = html.substring(lastIndex, match.index);
|
||||
if (textBefore) {
|
||||
// 转义文本中的花括号 - 使用占位符避免重复替换
|
||||
const escaped = textBefore
|
||||
.replace(/\{/g, "__LBRACE__")
|
||||
.replace(/\}/g, "__RBRACE__");
|
||||
parts.push(escaped);
|
||||
}
|
||||
// 添加标签本身(不转义)
|
||||
parts.push(match[0]);
|
||||
lastIndex = tagRegex.lastIndex;
|
||||
}
|
||||
|
||||
// 添加最后一段文本
|
||||
const textAfter = html.substring(lastIndex);
|
||||
if (textAfter) {
|
||||
const escaped = textAfter
|
||||
.replace(/\{/g, "__LBRACE__")
|
||||
.replace(/\}/g, "__RBRACE__");
|
||||
parts.push(escaped);
|
||||
}
|
||||
|
||||
return parts.join('')
|
||||
.replace(/__LBRACE__/g, "{'{'}")
|
||||
.replace(/__RBRACE__/g, "{'}'}")
|
||||
}
|
||||
|
||||
function convertCommonAttributesToJSX(content) {
|
||||
let nextContent = content;
|
||||
|
||||
nextContent = nextContent.replace(/\s(?:srcset|sizes)=(["'])\1/gi, '');
|
||||
|
||||
JSX_ATTRIBUTE_REPLACEMENTS.forEach(([from, to]) => {
|
||||
nextContent = nextContent.replace(new RegExp(`(\\s)${from}=`, 'gi'), `$1${to}=`);
|
||||
});
|
||||
|
||||
return nextContent;
|
||||
}
|
||||
|
||||
function findCompatibleImageAsset(filename, availableAssets) {
|
||||
if (!filename || availableAssets.has(filename)) {
|
||||
return filename;
|
||||
}
|
||||
|
||||
const extension = path.extname(filename);
|
||||
const basename = path.basename(filename, extension);
|
||||
const dedupeMatch = basename.match(/^(.*?)-\d+$/);
|
||||
if (!dedupeMatch) {
|
||||
return filename;
|
||||
}
|
||||
|
||||
const fallbackName = `${dedupeMatch[1]}${extension}`;
|
||||
return availableAssets.has(fallbackName) ? fallbackName : filename;
|
||||
}
|
||||
|
||||
function reconcileImageAssetReferences(content, imagesPath) {
|
||||
if (!content || !fs.existsSync(imagesPath)) {
|
||||
return content;
|
||||
}
|
||||
|
||||
const availableAssets = new Set(
|
||||
fs.readdirSync(imagesPath, { withFileTypes: true })
|
||||
.filter((entry) => entry.isFile())
|
||||
.map((entry) => entry.name),
|
||||
);
|
||||
|
||||
return content.replace(/assets\/images\/([^"')\s>]+)/g, (fullMatch, filename) => {
|
||||
const resolvedFilename = findCompatibleImageAsset(filename, availableAssets);
|
||||
if (resolvedFilename === filename) {
|
||||
return fullMatch;
|
||||
}
|
||||
return `assets/images/${resolvedFilename}`;
|
||||
});
|
||||
}
|
||||
|
||||
function createCommentPlaceholders(content) {
|
||||
const comments = [];
|
||||
const withPlaceholders = content.replace(/<!--([\s\S]*?)-->/g, (_, commentBody) => {
|
||||
const placeholder = `__HTML_COMMENT_${comments.length}__`;
|
||||
comments.push(`{/* ${commentBody} */}`);
|
||||
return placeholder;
|
||||
});
|
||||
|
||||
return { withPlaceholders, comments };
|
||||
}
|
||||
|
||||
function restoreCommentPlaceholders(content, comments) {
|
||||
return comments.reduce(
|
||||
(currentContent, comment, index) => currentContent.replaceAll(`__HTML_COMMENT_${index}__`, comment),
|
||||
content,
|
||||
);
|
||||
}
|
||||
|
||||
function convertHtmlToJSX(content) {
|
||||
let nextContent = convertCommonAttributesToJSX(content)
|
||||
.replace(/(<pre[^>]*>)([\s\S]*?)(<\/pre>)/gi, (_, openTag, preContent) => {
|
||||
const escapedContent = preContent
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/`/g, '\\`')
|
||||
.replace(/\$/g, '\\$')
|
||||
.replace(/\{/g, '\\{');
|
||||
return `${openTag.slice(0, -1)} dangerouslySetInnerHTML={{ __html: \`${escapedContent}\` }} />`;
|
||||
})
|
||||
.replace(/style='([^']*)'/gi, (_, styleStr) => convertStyleToJSX(styleStr))
|
||||
.replace(/style="([^"]*)"/gi, (_, styleStr) => convertStyleToJSX(styleStr))
|
||||
.replace(/<\/(br|hr|img|input|meta|link)>/gi, '')
|
||||
.replace(/<(br|hr|img|input|meta|link)([^>]*)>/gi, '<$1$2 />')
|
||||
.replace(/<body\b([^>]*)>/gi, '<div data-chrome-export-body="true"$1>')
|
||||
.replace(/<\/body>/gi, '</div>')
|
||||
.replace(/<\//g, '__LTSLASH__')
|
||||
.replace(/</g, '__LT__')
|
||||
.replace(/>/g, '__GT__')
|
||||
.replace(/&/g, '__AMP__');
|
||||
|
||||
const { withPlaceholders, comments } = createCommentPlaceholders(nextContent);
|
||||
nextContent = escapeTextBraces(withPlaceholders);
|
||||
nextContent = restoreCommentPlaceholders(nextContent, comments);
|
||||
|
||||
return nextContent
|
||||
.replace(/__LTSLASH__/g, "{'</'}")
|
||||
.replace(/__LT__/g, "{'<'}")
|
||||
.replace(/__GT__/g, "{'>'}")
|
||||
.replace(/__AMP__/g, '&');
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取并转换 body 内容
|
||||
*/
|
||||
function extractBodyContent(html) {
|
||||
const openTagMatch = html.match(/<body\b[^>]*>/i);
|
||||
if (!openTagMatch || openTagMatch.index === undefined) return '';
|
||||
|
||||
const openTag = openTagMatch[0];
|
||||
const contentStart = openTagMatch.index + openTag.length;
|
||||
const closeTagIndex = html.toLowerCase().lastIndexOf('</body>');
|
||||
if (closeTagIndex < contentStart) return '';
|
||||
|
||||
const innerContent = html.slice(contentStart, closeTagIndex);
|
||||
|
||||
// 移除 <root> 标签(Chrome 扩展导出特有的包装标签)
|
||||
let cleanedContent = innerContent.trim()
|
||||
.replace(/^\s*<root>\s*/i, '')
|
||||
.replace(/\s*<\/root>\s*$/i, '');
|
||||
|
||||
const convertedOpenTag = convertCommonAttributesToJSX(openTag)
|
||||
.replace(/^<body\b/i, '<div data-chrome-export-root="true"');
|
||||
const content = convertHtmlToJSX(cleanedContent);
|
||||
|
||||
return `${convertedOpenTag}\n${content}\n </div>`;
|
||||
}
|
||||
|
||||
function convertStyleToJSX(styleStr) {
|
||||
if (!styleStr.trim()) return 'style={{}}';
|
||||
|
||||
// 先解码 HTML 实体
|
||||
const decodedStr = styleStr
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/&/g, '&');
|
||||
|
||||
const styles = [];
|
||||
let currentProp = '';
|
||||
let inUrl = false;
|
||||
|
||||
for (let i = 0; i < decodedStr.length; i++) {
|
||||
const char = decodedStr[i];
|
||||
if (char === '(' && decodedStr.substring(i - 3, i) === 'url') inUrl = true;
|
||||
else if (char === ')' && inUrl) inUrl = false;
|
||||
|
||||
if (char === ';' && !inUrl) {
|
||||
if (currentProp.trim()) styles.push(currentProp.trim());
|
||||
currentProp = '';
|
||||
} else {
|
||||
currentProp += char;
|
||||
}
|
||||
}
|
||||
if (currentProp.trim()) styles.push(currentProp.trim());
|
||||
|
||||
const jsxStyles = styles
|
||||
.filter(s => s.includes(':'))
|
||||
.map(s => {
|
||||
const colonIndex = s.indexOf(':');
|
||||
const key = s.substring(0, colonIndex).trim();
|
||||
const value = s.substring(colonIndex + 1).trim();
|
||||
if (!key || !value) return '';
|
||||
|
||||
const camelKey = key.startsWith('-')
|
||||
? JSON.stringify(key)
|
||||
: key.replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
||||
let jsxValue;
|
||||
if (value.startsWith('url(') || value.includes('var(')) {
|
||||
jsxValue = `'${value.replace(/'/g, "\\'")}'`;
|
||||
} else if (/^-?\d+(\.\d+)?$/.test(value)) {
|
||||
jsxValue = value;
|
||||
} else {
|
||||
// 转义单引号和反斜杠
|
||||
const escapedValue = value.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
||||
jsxValue = `'${escapedValue}'`;
|
||||
}
|
||||
return `${camelKey}: ${jsxValue}`;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
|
||||
return `style={{ ${jsxStyles} }}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成组件代码
|
||||
*/
|
||||
function normalizeDisplayName(displayName) {
|
||||
const text = String(displayName ?? '').trim();
|
||||
const singleLine = text.replace(/\r?\n/g, ' ');
|
||||
const safeText = singleLine.replace(/\*\//g, '* /');
|
||||
return safeText.slice(0, 200);
|
||||
}
|
||||
|
||||
function generateComponent(pageSlug, displayName, bodyContent, headContent) {
|
||||
const componentName = pageSlug
|
||||
.split(/[-_\s]+/)
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join('');
|
||||
const safeDisplayName = normalizeDisplayName(displayName || pageSlug);
|
||||
|
||||
let cleanedContent = bodyContent.trim();
|
||||
if (cleanedContent.startsWith('{/*')) {
|
||||
const firstTagIndex = cleanedContent.indexOf('<');
|
||||
if (firstTagIndex > 0) {
|
||||
cleanedContent = cleanedContent.substring(firstTagIndex);
|
||||
}
|
||||
}
|
||||
|
||||
const needsWrapper = !isWrappedInSingleElement(cleanedContent);
|
||||
const finalContent = needsWrapper ? `<>\n${cleanedContent}\n </>` : cleanedContent;
|
||||
|
||||
// 生成注入代码
|
||||
let injectionCode = '';
|
||||
|
||||
if (headContent.links.length > 0 || headContent.scripts.length > 0) {
|
||||
const hasExternalScripts = headContent.scripts.some(s => s.src);
|
||||
|
||||
injectionCode = `
|
||||
// 动态注入外部资源
|
||||
React.useEffect(function () {
|
||||
const injected: (HTMLElement)[] = [];
|
||||
`;
|
||||
|
||||
if (headContent.links.length > 0) {
|
||||
injectionCode += `
|
||||
// 注入 links
|
||||
${JSON.stringify(headContent.links)}.forEach(function (linkInfo: any) {
|
||||
const existing = document.querySelector(\`link[href="\${linkInfo.href}"]\`);
|
||||
if (!existing) {
|
||||
const link = document.createElement('link');
|
||||
link.rel = linkInfo.rel;
|
||||
link.href = linkInfo.href;
|
||||
if (linkInfo.crossorigin) link.crossOrigin = 'anonymous';
|
||||
document.head.appendChild(link);
|
||||
injected.push(link);
|
||||
}
|
||||
});
|
||||
`;
|
||||
}
|
||||
|
||||
if (hasExternalScripts) {
|
||||
injectionCode += `
|
||||
// 注入外部脚本
|
||||
${JSON.stringify(headContent.scripts.filter(s => s.src))}.forEach(function (scriptInfo: any) {
|
||||
const existing = document.querySelector(\`script[src="\${scriptInfo.src}"]\`);
|
||||
if (!existing) {
|
||||
const script = document.createElement('script');
|
||||
if (scriptInfo.id) script.id = scriptInfo.id;
|
||||
script.src = scriptInfo.src;
|
||||
document.head.appendChild(script);
|
||||
injected.push(script);
|
||||
}
|
||||
});
|
||||
`;
|
||||
}
|
||||
|
||||
injectionCode += `
|
||||
return function () {
|
||||
injected.forEach(function (el) {
|
||||
if (el.parentNode) el.parentNode.removeChild(el);
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
`;
|
||||
}
|
||||
|
||||
return `/**
|
||||
* @name ${safeDisplayName}
|
||||
*
|
||||
* 参考资料:
|
||||
* - /rules/development-guide.md
|
||||
* - /rules/default-resource-recommendations.md
|
||||
*/
|
||||
|
||||
import './style.css';
|
||||
import React, { forwardRef, useImperativeHandle } from 'react';
|
||||
import type { AxureProps, AxureHandle } from '../../common/axure-types';
|
||||
|
||||
class ErrorBoundary extends React.Component<
|
||||
{ children: React.ReactNode },
|
||||
{ hasError: boolean; error: Error | null }
|
||||
> {
|
||||
constructor(props: any) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error('[${pageSlug}] 组件渲染错误:', error);
|
||||
console.error('[${pageSlug}] 错误详情:', errorInfo);
|
||||
console.error('[${pageSlug}] 错误堆栈:', error.stack);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div style={{ padding: '20px', color: 'red', border: '2px solid red', margin: '20px' }}>
|
||||
<h2>组件渲染失败: ${safeDisplayName}</h2>
|
||||
<pre style={{ whiteSpace: 'pre-wrap', fontSize: '12px' }}>
|
||||
{this.state.error?.toString()}
|
||||
{this.state.error?.stack}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
const Component = forwardRef(function ${componentName}(
|
||||
innerProps: AxureProps,
|
||||
ref: React.ForwardedRef<AxureHandle>,
|
||||
) {
|
||||
console.log('[${pageSlug}] 组件开始渲染');
|
||||
|
||||
useImperativeHandle(ref, function () {
|
||||
return {
|
||||
getVar: function () { return undefined; },
|
||||
fireAction: function () {},
|
||||
eventList: [],
|
||||
actionList: [],
|
||||
varList: [],
|
||||
configList: [],
|
||||
dataList: []
|
||||
};
|
||||
}, []);
|
||||
${injectionCode}
|
||||
console.log('[${pageSlug}] 准备返回 JSX');
|
||||
|
||||
try {
|
||||
return (
|
||||
${finalContent.split('\n').map(line => ' ' + line).join('\n')}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('[${pageSlug}] JSX 渲染错误:', error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
const WrappedComponent = forwardRef(function WrappedComponent(
|
||||
props: AxureProps,
|
||||
ref: React.ForwardedRef<AxureHandle>,
|
||||
) {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<Component {...props} ref={ref} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
});
|
||||
|
||||
export default WrappedComponent;
|
||||
`;
|
||||
}
|
||||
|
||||
function isWrappedInSingleElement(content) {
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed.startsWith('<')) return false;
|
||||
if (trimmed.startsWith('<body')) return trimmed.endsWith('</body>');
|
||||
|
||||
const firstTagMatch = trimmed.match(/^<([a-zA-Z][a-zA-Z0-9]*)/);
|
||||
if (!firstTagMatch) return false;
|
||||
|
||||
const tagName = firstTagMatch[1];
|
||||
const closingTag = `</${tagName}>`;
|
||||
if (!trimmed.endsWith(closingTag)) return false;
|
||||
|
||||
const openCount = (trimmed.match(new RegExp(`<${tagName}[\\s>]`, 'g')) || []).length;
|
||||
const closeCount = (trimmed.match(new RegExp(`</${tagName}>`, 'g')) || []).length;
|
||||
return openCount === closeCount && openCount === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 CSS 文件(仅处理外部 style.css)
|
||||
*/
|
||||
function generateStyleCSS(fonts, sourcePath) {
|
||||
let css = '@import "tailwindcss";\n';
|
||||
|
||||
// 处理字体
|
||||
if (fonts && fonts.length > 0) {
|
||||
css += '\n/* 字体定义 */\n';
|
||||
|
||||
const cdnFonts = fonts.filter(f => f.isCDN);
|
||||
const localFonts = fonts.filter(f => !f.isCDN);
|
||||
|
||||
if (cdnFonts.length > 0) {
|
||||
css += '\n/* CDN 字体(保留原始链接) */\n';
|
||||
cdnFonts.forEach(font => {
|
||||
css += font.rule + '\n\n';
|
||||
});
|
||||
}
|
||||
|
||||
if (localFonts.length > 0) {
|
||||
css += '\n/* 本地字体(已复制到 assets 目录) */\n';
|
||||
localFonts.forEach(font => {
|
||||
// 将字体路径改为相对于 style.css 的路径
|
||||
const modifiedRule = font.rule.replace(
|
||||
/url\(['"]?([^'")\s]+)['"]?\)/g,
|
||||
(match, url) => `url('./${url}')`
|
||||
);
|
||||
css += modifiedRule + '\n\n';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 读取外部 CSS 文件的完整内容(排除字体定义)
|
||||
const externalCSSPath = path.join(sourcePath, 'style.css');
|
||||
if (fs.existsSync(externalCSSPath)) {
|
||||
const externalCSS = fs.readFileSync(externalCSSPath, 'utf8');
|
||||
// 移除 @font-face 规则(已单独处理)
|
||||
const withoutFontFace = externalCSS.replace(/@font-face\s*\{[^}]+\}/g, '').trim();
|
||||
if (withoutFontFace) {
|
||||
css += '\n/* 样式类定义(来自 style.css)*/\n';
|
||||
css += withoutFontFace + '\n';
|
||||
}
|
||||
}
|
||||
|
||||
return css;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从外部 CSS 文件提取字体
|
||||
*/
|
||||
function extractFontsFromCSS(cssPath) {
|
||||
if (!fs.existsSync(cssPath)) return [];
|
||||
|
||||
const css = fs.readFileSync(cssPath, 'utf8');
|
||||
const fonts = [];
|
||||
|
||||
const fontFaceRegex = /@font-face\s*\{([^}]+)\}/g;
|
||||
let match;
|
||||
while ((match = fontFaceRegex.exec(css)) !== null) {
|
||||
const fontRule = match[1];
|
||||
const srcMatch = fontRule.match(/src:\s*url\(['"]?([^'")\s]+)['"]?\)/);
|
||||
const familyMatch = fontRule.match(/font-family:\s*['"]([^'"]+)['"]/);
|
||||
|
||||
if (srcMatch && familyMatch) {
|
||||
const fontSrc = srcMatch[1];
|
||||
const fontFamily = familyMatch[1];
|
||||
|
||||
fonts.push({
|
||||
family: fontFamily,
|
||||
src: fontSrc,
|
||||
isCDN: isCDNUrl(fontSrc),
|
||||
rule: match[0]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return fonts;
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换单个页面
|
||||
*/
|
||||
function convertPage(sourcePath, outputDir, pageSlug, displayName) {
|
||||
log(`正在转换页面: ${pageSlug}`, 'progress');
|
||||
|
||||
// Chrome 扩展导出固定使用 index.html
|
||||
const htmlPath = path.join(sourcePath, 'index.html');
|
||||
|
||||
if (!fs.existsSync(htmlPath)) {
|
||||
throw new Error(`找不到 index.html 文件: ${htmlPath}`);
|
||||
}
|
||||
|
||||
const html = fs.readFileSync(htmlPath, 'utf8');
|
||||
|
||||
const headContent = extractHeadContent(html);
|
||||
const bodyContent = reconcileImageAssetReferences(
|
||||
extractBodyContent(html),
|
||||
path.join(sourcePath, 'assets', 'images'),
|
||||
);
|
||||
|
||||
// 从外部 CSS 文件提取字体
|
||||
const externalCSSPath = path.join(sourcePath, 'style.css');
|
||||
let fonts = [];
|
||||
if (fs.existsSync(externalCSSPath)) {
|
||||
fonts = extractFontsFromCSS(externalCSSPath);
|
||||
if (fonts.length > 0) {
|
||||
log(` ✓ 从 style.css 提取了 ${fonts.length} 个字体定义`, 'info');
|
||||
}
|
||||
}
|
||||
|
||||
ensureDir(outputDir);
|
||||
|
||||
// 生成组件和样式
|
||||
const componentCode = generateComponent(pageSlug, displayName, bodyContent, headContent);
|
||||
const styleCSS = generateStyleCSS(fonts, sourcePath);
|
||||
const outputTsxPath = path.join(outputDir, 'index.tsx');
|
||||
|
||||
assertValidGeneratedTsx(componentCode, outputTsxPath);
|
||||
|
||||
fs.writeFileSync(outputTsxPath, componentCode);
|
||||
fs.writeFileSync(path.join(outputDir, 'style.css'), styleCSS);
|
||||
|
||||
// 复制静态资源
|
||||
const assetsPath = path.join(sourcePath, 'assets');
|
||||
if (fs.existsSync(assetsPath)) {
|
||||
const outputAssetsPath = path.join(outputDir, 'assets');
|
||||
|
||||
// 复制图片
|
||||
const imagesPath = path.join(assetsPath, 'images');
|
||||
if (fs.existsSync(imagesPath)) {
|
||||
const imageCount = copyDirectory(imagesPath, path.join(outputAssetsPath, 'images'));
|
||||
if (imageCount > 0) {
|
||||
log(` ✓ 复制了 ${imageCount} 个图片文件`, 'info');
|
||||
}
|
||||
}
|
||||
|
||||
// 复制本地字体
|
||||
const localFonts = fonts.filter(f => !f.isCDN);
|
||||
if (localFonts.length > 0) {
|
||||
const fontsPath = path.join(assetsPath, 'fonts');
|
||||
if (fs.existsSync(fontsPath)) {
|
||||
const fontCount = copyDirectory(fontsPath, path.join(outputAssetsPath, 'fonts'));
|
||||
log(` ✓ 复制了 ${fontCount} 个字体文件`, 'info');
|
||||
}
|
||||
}
|
||||
|
||||
// 统计 CDN 字体
|
||||
const cdnFonts = fonts.filter(f => f.isCDN);
|
||||
if (cdnFonts.length > 0) {
|
||||
log(` ✓ 保留了 ${cdnFonts.length} 个 CDN 字体链接`, 'info');
|
||||
}
|
||||
}
|
||||
|
||||
// 复制参考文件(如果存在)
|
||||
const filesToCopy = ['screenshot.png', 'content.md', 'theme.json'];
|
||||
filesToCopy.forEach(filename => {
|
||||
const srcFile = path.join(sourcePath, filename);
|
||||
if (fs.existsSync(srcFile)) {
|
||||
const destFile = path.join(outputDir, filename);
|
||||
fs.copyFileSync(srcFile, destFile);
|
||||
log(` ✓ 复制了 ${filename}`, 'info');
|
||||
}
|
||||
});
|
||||
|
||||
log(`页面转换完成: ${pageSlug}`, 'info');
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测项目类型(仅支持 Chrome 扩展导出)
|
||||
*/
|
||||
function detectProjectType(sourcePath) {
|
||||
const items = fs.readdirSync(sourcePath);
|
||||
|
||||
// 检查是否为 Chrome 扩展导出格式(有 index.html)
|
||||
if (items.includes('index.html')) {
|
||||
return { type: 'chrome-export', prototypes: [{ name: 'index', path: sourcePath }] };
|
||||
}
|
||||
|
||||
throw new Error('未找到 index.html 文件,请确认这是 Chrome 扩展导出的项目');
|
||||
}
|
||||
|
||||
/**
|
||||
* 主函数
|
||||
*/
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
if (args.length === 0 || args[0] === '--help') {
|
||||
console.log(`
|
||||
Chrome 扩展导出转换器
|
||||
|
||||
使用方法:
|
||||
node scripts/chrome-export-converter.mjs <source-dir> [output-name] [display-name]
|
||||
node scripts/chrome-export-converter.mjs <source-dir> --name <output-name> --display-name <display-name> --target-dir <relative-dir>
|
||||
|
||||
参数说明:
|
||||
source-dir : Chrome 扩展导出的目录(包含 index.html)
|
||||
output-name : 输出页面名称(可选,默认使用目录名)
|
||||
display-name : 页面显示名(可选,写入 index.tsx 的 @name)
|
||||
target-dir : 输出到 src/prototypes 下的相对目录(可选)
|
||||
|
||||
示例:
|
||||
node scripts/chrome-export-converter.mjs ".drafts/my-export" my-page
|
||||
node scripts/chrome-export-converter.mjs ".drafts/my-export" my-page "登录页"
|
||||
node scripts/chrome-export-converter.mjs ".drafts/my-export" --name my-page --target-dir grouped/login-page
|
||||
`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const flags = {};
|
||||
const positionals = [];
|
||||
for (let i = 0; i < args.length; i += 1) {
|
||||
const token = args[i];
|
||||
if (token === '--name' || token === '--display-name' || token === '--target-dir') {
|
||||
const next = args[i + 1];
|
||||
if (typeof next === 'string' && next) {
|
||||
flags[token] = next;
|
||||
i += 1;
|
||||
} else {
|
||||
flags[token] = '';
|
||||
}
|
||||
continue;
|
||||
}
|
||||
positionals.push(token);
|
||||
}
|
||||
|
||||
const sourceDirArg = positionals[0];
|
||||
const outputNameRaw = flags['--name'] || positionals[1] || path.basename(sourceDirArg);
|
||||
const outputName = String(outputNameRaw)
|
||||
.replace(/[^a-z0-9-]/gi, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.toLowerCase();
|
||||
const displayNameRaw = flags['--display-name'] ?? positionals[2];
|
||||
const displayName = (displayNameRaw !== undefined ? String(displayNameRaw).trim() : '') || outputName;
|
||||
if (displayNameRaw !== undefined) {
|
||||
const trimmedDisplayName = String(displayNameRaw).trim();
|
||||
if (!trimmedDisplayName || trimmedDisplayName.length > 200) {
|
||||
throw new Error('displayName 长度必须在 1-200 字符');
|
||||
}
|
||||
}
|
||||
|
||||
const requestedTargetDir = normalizeRelativeDir(flags['--target-dir'] || outputName);
|
||||
if (!isSafeRelativeDir(requestedTargetDir)) {
|
||||
throw new Error('target-dir 必须是 src/prototypes 下的安全相对路径');
|
||||
}
|
||||
|
||||
const sourcePath = path.resolve(CONFIG.projectRoot, sourceDirArg);
|
||||
const outputDir = path.resolve(CONFIG.pagesDir, requestedTargetDir);
|
||||
const resolvedPagesDir = path.resolve(CONFIG.pagesDir);
|
||||
if (outputDir === resolvedPagesDir || !outputDir.startsWith(`${resolvedPagesDir}${path.sep}`)) {
|
||||
throw new Error('target-dir 超出 src/prototypes 目录范围');
|
||||
}
|
||||
|
||||
if (!fs.existsSync(sourcePath)) {
|
||||
log(`错误: 找不到目录 ${sourcePath}`, 'error');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
log('开始转换 Chrome 扩展导出...', 'info');
|
||||
|
||||
const { type, prototypes } = detectProjectType(sourcePath);
|
||||
log(`项目类型: ${type}`, 'info');
|
||||
|
||||
convertPage(prototypes[0].path, outputDir, outputName, displayName);
|
||||
log('✅ 转换完成!', 'info');
|
||||
log(`📁 页面位置: ${outputDir}`, 'info');
|
||||
|
||||
} catch (error) {
|
||||
log(`转换失败: ${error.message}`, 'error');
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
|
||||
main();
|
||||
}
|
||||
|
||||
export {
|
||||
convertCommonAttributesToJSX,
|
||||
convertHtmlToJSX,
|
||||
convertStyleToJSX,
|
||||
extractBodyContent,
|
||||
generateComponent,
|
||||
reconcileImageAssetReferences,
|
||||
};
|
||||
259
scripts/convert-v266-contract.py
Normal file
259
scripts/convert-v266-contract.py
Normal file
@@ -0,0 +1,259 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Convert textutil Word HTML export to ct-word-doc seed for ContractTemplate."""
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Set
|
||||
|
||||
from bs4 import BeautifulSoup, NavigableString, Tag
|
||||
|
||||
SRC = Path("/tmp/v266-contract.html")
|
||||
OUT = Path(__file__).resolve().parent.parent / "src/prototypes/contract-template-management/v266-lease-document.js"
|
||||
|
||||
# Semantic ct-word classes for key blocks (layout still driven by embedded CSS)
|
||||
SEMANTIC = {
|
||||
"p2": "ct-word-title",
|
||||
"p3": "ct-word-contract-no",
|
||||
"p5": "ct-word-party-line",
|
||||
"p6": "ct-word-party-line",
|
||||
"p7": "ct-word-party-line",
|
||||
"p8": "ct-word-party-line",
|
||||
}
|
||||
|
||||
|
||||
def parse_css_rules(css_text: str) -> Dict[str, str]:
|
||||
rules: Dict[str, str] = {}
|
||||
for block in re.findall(r"([^{]+)\{([^}]+)\}", css_text):
|
||||
selector = block[0].strip()
|
||||
props = block[1].strip()
|
||||
if "," in selector:
|
||||
continue
|
||||
selector = selector.lstrip(".")
|
||||
if re.match(r"^(p|span|td|table)\.", selector):
|
||||
rules[selector] = props
|
||||
return rules
|
||||
|
||||
|
||||
def is_red_style(style: str) -> bool:
|
||||
return bool(re.search(r"color\s*:\s*#ff0000", style, re.I))
|
||||
|
||||
|
||||
def build_red_classes(rules: Dict[str, str]) -> Set[str]:
|
||||
red = set()
|
||||
for cls, props in rules.items():
|
||||
if is_red_style(props):
|
||||
red.add(cls.split(".")[-1])
|
||||
return red
|
||||
|
||||
|
||||
def class_list(tag: Tag) -> List[str]:
|
||||
raw = tag.get("class") or []
|
||||
if isinstance(raw, str):
|
||||
return raw.split()
|
||||
return list(raw)
|
||||
|
||||
|
||||
def primary_class(tag: Tag) -> Optional[str]:
|
||||
classes = class_list(tag)
|
||||
for c in classes:
|
||||
if c in SEMANTIC or re.match(r"^[pst]\d+$", c) or re.match(r"^td\d+$", c):
|
||||
return c
|
||||
return classes[0] if classes else None
|
||||
|
||||
|
||||
def merge_style(tag: Tag, rules: Dict[str, str]) -> None:
|
||||
parts: List[str] = []
|
||||
for c in class_list(tag):
|
||||
key_p = f"p.{c}" if c.startswith("p") else None
|
||||
key_s = f"span.{c}" if c.startswith("s") else None
|
||||
key_td = f"td.{c}" if c.startswith("td") else None
|
||||
key_t = f"table.{c}" if c.startswith("t") else None
|
||||
for key in (key_p, key_s, key_td, key_t):
|
||||
if key and key in rules:
|
||||
parts.append(rules[key])
|
||||
if parts:
|
||||
existing = tag.get("style", "")
|
||||
merged = ";".join([existing] + parts) if existing else ";".join(parts)
|
||||
tag["style"] = merged
|
||||
|
||||
|
||||
def strip_apple_noise(soup: BeautifulSoup) -> None:
|
||||
for el in soup.find_all(class_="Apple-converted-space"):
|
||||
el.replace_with("\u00a0" * max(1, len(el.get_text())))
|
||||
for el in soup.find_all(class_="Apple-tab-span"):
|
||||
el.replace_with("\t")
|
||||
|
||||
|
||||
def wrap_risk_redlines(soup: BeautifulSoup, red_classes: Set[str]) -> None:
|
||||
risk_seq = 0
|
||||
|
||||
def next_id() -> str:
|
||||
nonlocal risk_seq
|
||||
risk_seq += 1
|
||||
return f"risk-{risk_seq}"
|
||||
|
||||
def is_red_tag(tag: Tag) -> bool:
|
||||
pc = primary_class(tag)
|
||||
return pc in red_classes if pc else False
|
||||
|
||||
# Block-level red paragraphs: wrap inner content once
|
||||
for p in list(soup.find_all("p")):
|
||||
if not is_red_tag(p):
|
||||
continue
|
||||
if p.find_parent(class_=lambda x: x and "ct-risk-redline" in x):
|
||||
continue
|
||||
inner = list(p.contents)
|
||||
if not inner:
|
||||
continue
|
||||
wrapper = soup.new_tag(
|
||||
"span",
|
||||
attrs={
|
||||
"class": "ct-risk-redline",
|
||||
"data-risk-redline": "1",
|
||||
"data-risk-id": next_id(),
|
||||
},
|
||||
)
|
||||
for child in inner:
|
||||
wrapper.append(child.extract() if isinstance(child, Tag) else child)
|
||||
p.clear()
|
||||
p.append(wrapper)
|
||||
|
||||
# Inline red spans inside non-red paragraphs
|
||||
for span in list(soup.find_all("span")):
|
||||
if not is_red_tag(span):
|
||||
continue
|
||||
if span.find_parent(class_=lambda x: x and "ct-risk-redline" in x):
|
||||
continue
|
||||
parent_p = span.find_parent("p")
|
||||
if parent_p and is_red_tag(parent_p):
|
||||
continue
|
||||
wrapper = soup.new_tag(
|
||||
"span",
|
||||
attrs={
|
||||
"class": "ct-risk-redline",
|
||||
"data-risk-redline": "1",
|
||||
"data-risk-id": next_id(),
|
||||
},
|
||||
)
|
||||
span.wrap(wrapper)
|
||||
|
||||
|
||||
def apply_semantic_classes(root: Tag) -> None:
|
||||
for tag in root.find_all(True):
|
||||
pc = primary_class(tag)
|
||||
if pc and pc in SEMANTIC:
|
||||
classes = class_list(tag)
|
||||
extra = SEMANTIC[pc]
|
||||
if extra not in classes:
|
||||
tag["class"] = classes + [extra]
|
||||
|
||||
|
||||
def map_tables(root: Tag) -> None:
|
||||
for i, table in enumerate(root.find_all("table")):
|
||||
classes = class_list(table)
|
||||
if "ct-word-table" not in classes:
|
||||
table["class"] = classes + ["ct-doc-table", "ct-word-table"]
|
||||
if i == 0:
|
||||
for td in table.find_all("td"):
|
||||
tdc = primary_class(td)
|
||||
tr = td.find_parent("tr")
|
||||
row_idx = len(list(tr.find_previous_siblings("tr"))) if tr else 0
|
||||
if row_idx == 0:
|
||||
if tdc == "td1":
|
||||
td["class"] = class_list(td) + ["ct-word-party-left"]
|
||||
elif tdc == "td2":
|
||||
td["class"] = class_list(td) + ["ct-word-party-right"]
|
||||
else:
|
||||
td["class"] = class_list(td) + ["ct-word-td"]
|
||||
|
||||
|
||||
def apply_template_vars(html: str) -> str:
|
||||
regex_replacements = [
|
||||
(r"合同编号<span[^>]*>:</span>【LNZLHT\s*<span[^>]*>[\s\S]*?</span>\s*】",
|
||||
"合同编号:【LNZLHT {{contractCode}} 】"),
|
||||
(r"合同编号:【LNZLHT[^】]*】", "合同编号:【LNZLHT {{contractCode}} 】"),
|
||||
]
|
||||
literal_replacements = [
|
||||
("甲方(出租方):羚牛氢能科技(广东)有限公司", "甲方(出租方):{{lessorName}}"),
|
||||
("甲方(出租方): 羚牛氢能科技(广东)有限公司", "甲方(出租方): {{lessorName}}"),
|
||||
("甲方(出租方): 羚牛氢能科技(广东)有限公司", "甲方(出租方): {{lessorName}}"),
|
||||
("甲方:羚牛氢能科技(广东)有限公司", "甲方:{{lessorName}}"),
|
||||
("致:羚牛氢能科技(广东)有限公司", "致:{{lessorName}}"),
|
||||
("户 名:【羚牛氢能科技(广东)有限公司", "户 名:【{{lessorAccountName}}"),
|
||||
("开户行:【招商银行广州萝岗支行 】", "开户行:【{{lessorBankName}} 】"),
|
||||
("账 号:【120924165110201 】", "账 号:【{{lessorBankAccount}} 】"),
|
||||
("乙方</b><span class=\"s3\"><b>(承租方):</b></span>",
|
||||
"乙方</b><span class=\"s3\"><b>(承租方):{{customerName}}</b></span>"),
|
||||
("乙方(承租方):</b>", "乙方(承租方): {{customerName}}</b>"),
|
||||
("乙方(承租方):</b>", "乙方(承租方):{{customerName}}</b>"),
|
||||
]
|
||||
out = html
|
||||
for pattern, repl in regex_replacements:
|
||||
out = re.sub(pattern, repl, out)
|
||||
for old, new in literal_replacements:
|
||||
out = out.replace(old, new)
|
||||
return out
|
||||
|
||||
|
||||
def scope_css(css_text: str) -> str:
|
||||
scoped = []
|
||||
for block in re.findall(r"([^{]+)\{([^}]+)\}", css_text):
|
||||
selector = block[0].strip()
|
||||
props = block[1].strip()
|
||||
if selector.startswith("@") or "," in selector:
|
||||
continue
|
||||
scoped.append(f".ct-word-doc--v266 {selector}{{{props}}}")
|
||||
return "\n".join(scoped)
|
||||
|
||||
|
||||
def convert() -> str:
|
||||
raw = SRC.read_text(encoding="utf-8")
|
||||
css_match = re.search(r"<style[^>]*>([\s\S]*?)</style>", raw)
|
||||
body_match = re.search(r"<body>([\s\S]*?)</body>", raw)
|
||||
if not css_match or not body_match:
|
||||
raise SystemExit("Invalid source HTML")
|
||||
|
||||
rules = parse_css_rules(css_match.group(1))
|
||||
red_classes = build_red_classes(rules)
|
||||
scoped = scope_css(css_match.group(1))
|
||||
|
||||
soup = BeautifulSoup(body_match.group(1), "lxml")
|
||||
# lxml adds html/body wrapper
|
||||
root = soup.body or soup
|
||||
strip_apple_noise(soup)
|
||||
|
||||
for tag in soup.find_all(True):
|
||||
merge_style(tag, rules)
|
||||
|
||||
wrap_risk_redlines(soup, red_classes)
|
||||
apply_semantic_classes(soup)
|
||||
map_tables(soup)
|
||||
|
||||
body_html = "".join(str(c) for c in (soup.body or soup).contents)
|
||||
body_html = apply_template_vars(body_html)
|
||||
body_html = re.sub(r"<p class=\"p1\"><br\s*/?></p>\s*", "", body_html, count=1)
|
||||
|
||||
doc = (
|
||||
'<div class="ct-word-doc ct-word-doc--v266">'
|
||||
f"<style>{scoped}</style>"
|
||||
f"{body_html}"
|
||||
"</div>"
|
||||
)
|
||||
return doc
|
||||
|
||||
|
||||
def main() -> None:
|
||||
html = convert()
|
||||
escaped = json.dumps(html, ensure_ascii=False)
|
||||
OUT.write_text(
|
||||
"// AUTO-GENERATED V26.6 Word HTML — do not edit by hand\n"
|
||||
f"export var V266_LEASE_DOCUMENT_HTML = {escaped};\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
print(f"Wrote {OUT} ({OUT.stat().st_size} bytes)")
|
||||
print(f"Redline markers: {html.count('data-risk-redline')}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
256
scripts/convert-word-doc.py
Normal file
256
scripts/convert-word-doc.py
Normal 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
118
scripts/cursor-usage-snapshot.py
Executable 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()
|
||||
339
scripts/enrich-prototype-registry-from-git.mjs
Normal file
339
scripts/enrich-prototype-registry-from-git.mjs
Normal 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();
|
||||
147
scripts/figma-make-converter.mjs
Normal file
147
scripts/figma-make-converter.mjs
Normal file
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const TASK_FILE_BY_TARGET = {
|
||||
prototypes: '.figma-make-tasks.md',
|
||||
components: '.figma-make-tasks.md',
|
||||
themes: '.figma-make-theme-tasks.md',
|
||||
};
|
||||
|
||||
function normalizeSlashes(input) {
|
||||
return String(input || '').replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
function sanitizeName(rawName) {
|
||||
return String(rawName || '')
|
||||
.replace(/[^a-z0-9-]/gi, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = [...argv];
|
||||
const projectDirArg = args.shift();
|
||||
const outputNameArg = args.shift();
|
||||
let targetType = 'prototypes';
|
||||
let projectRoot = process.cwd();
|
||||
let outputBaseDir = '';
|
||||
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
if (arg === '--target-type') {
|
||||
targetType = String(args[index + 1] || '').trim();
|
||||
index += 1;
|
||||
} else if (arg === '--project-root') {
|
||||
projectRoot = path.resolve(args[index + 1] || projectRoot);
|
||||
index += 1;
|
||||
} else if (arg === '--output-base-dir') {
|
||||
outputBaseDir = path.resolve(args[index + 1] || '');
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (!projectDirArg) {
|
||||
throw new Error('Missing project directory');
|
||||
}
|
||||
if (!TASK_FILE_BY_TARGET[targetType]) {
|
||||
throw new Error(`Unsupported targetType: ${targetType}`);
|
||||
}
|
||||
const outputName = sanitizeName(outputNameArg || path.basename(projectDirArg));
|
||||
if (!outputName) {
|
||||
throw new Error('Missing valid output name');
|
||||
}
|
||||
|
||||
return {
|
||||
projectDir: path.resolve(projectRoot, projectDirArg),
|
||||
outputName,
|
||||
targetType,
|
||||
projectRoot,
|
||||
outputBaseDir: outputBaseDir || path.resolve(projectRoot, 'src', targetType),
|
||||
};
|
||||
}
|
||||
|
||||
function copyDirectory(src, dest) {
|
||||
if (!fs.existsSync(src)) return 0;
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
let count = 0;
|
||||
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
||||
if (entry.name === 'node_modules' || entry.name === '.npm-local-cache' || entry.name === 'build') {
|
||||
continue;
|
||||
}
|
||||
const srcPath = path.join(src, entry.name);
|
||||
const destPath = path.join(dest, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
count += copyDirectory(srcPath, destPath);
|
||||
} else if (entry.isFile()) {
|
||||
fs.copyFileSync(srcPath, destPath);
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function ensureIndex(outputDir) {
|
||||
const indexPath = path.join(outputDir, 'index.tsx');
|
||||
if (!fs.existsSync(indexPath)) {
|
||||
fs.writeFileSync(indexPath, [
|
||||
"import React from 'react';",
|
||||
'',
|
||||
'export default function ImportedFigmaMakePrototype() {',
|
||||
' return <div data-import-source="figma_make">Figma Make import requires AI conversion.</div>;',
|
||||
'}',
|
||||
'',
|
||||
].join('\n'), 'utf8');
|
||||
}
|
||||
}
|
||||
|
||||
function writeTaskFile(params) {
|
||||
const taskFileName = TASK_FILE_BY_TARGET[params.targetType];
|
||||
const taskPath = path.join(params.outputDir, taskFileName);
|
||||
const relativeOutput = normalizeSlashes(path.relative(params.projectRoot, params.outputDir));
|
||||
const sourceContext = normalizeSlashes(path.relative(params.projectRoot, params.projectDir));
|
||||
const content = [
|
||||
'# Figma Make 项目转换任务清单',
|
||||
'',
|
||||
'> 请先阅读 `rules/development-guide.md`、`rules/design-guide.md` 和 `rules/default-resource-recommendations.md`,再基于该目录完成原型转换。',
|
||||
'',
|
||||
`- 输出目录:\`${relativeOutput}/\``,
|
||||
`- 上传上下文:\`${sourceContext}/\``,
|
||||
`- 已复制文件数:${params.fileCount}`,
|
||||
'',
|
||||
'## 执行要求',
|
||||
'- 保留原始 Figma Make 项目结构与素材。',
|
||||
'- 将可运行原型入口整理到 `index.tsx`。',
|
||||
'- 如已有 `src/App.tsx`,优先复用其页面结构。',
|
||||
'',
|
||||
].join('\n');
|
||||
fs.writeFileSync(taskPath, content, 'utf8');
|
||||
return taskPath;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const parsed = parseArgs(process.argv.slice(2));
|
||||
if (!fs.existsSync(path.join(parsed.projectDir, 'src')) || !fs.existsSync(path.join(parsed.projectDir, 'package.json'))) {
|
||||
throw new Error('这不是一个有效的 Figma Make 项目(需要包含 src/ 和 package.json)');
|
||||
}
|
||||
const outputDir = path.join(parsed.outputBaseDir, parsed.outputName);
|
||||
fs.rmSync(outputDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(parsed.outputBaseDir, { recursive: true });
|
||||
const fileCount = copyDirectory(parsed.projectDir, outputDir);
|
||||
ensureIndex(outputDir);
|
||||
const taskPath = writeTaskFile({ ...parsed, outputDir, fileCount });
|
||||
console.log(JSON.stringify({
|
||||
success: true,
|
||||
outputDir,
|
||||
tasksFile: normalizeSlashes(path.relative(parsed.projectRoot, taskPath)),
|
||||
}));
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
console.error(error?.message || String(error));
|
||||
process.exit(1);
|
||||
}
|
||||
14
scripts/lease-contract-theme-skins.mjs
Normal file
14
scripts/lease-contract-theme-skins.mjs
Normal 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';
|
||||
167
scripts/merge-registry-sidebar-conflicts.mjs
Normal file
167
scripts/merge-registry-sidebar-conflicts.mjs
Normal 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);
|
||||
62
scripts/migrate-prototype-annotation-host.mjs
Normal file
62
scripts/migrate-prototype-annotation-host.mjs
Normal 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`);
|
||||
193
scripts/pack-oneos-v2-design-handoff.mjs
Normal file
193
scripts/pack-oneos-v2-design-handoff.mjs
Normal 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();
|
||||
349
scripts/publish-all-to-s3.mjs
Normal file
349
scripts/publish-all-to-s3.mjs
Normal 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-v2;Make 多工程时务必传 --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);
|
||||
});
|
||||
112
scripts/remove-breadcrumbs-pass2.mjs
Normal file
112
scripts/remove-breadcrumbs-pass2.mjs
Normal 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).`);
|
||||
62
scripts/remove-breadcrumbs-pass3.mjs
Normal file
62
scripts/remove-breadcrumbs-pass3.mjs
Normal 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).`);
|
||||
213
scripts/remove-breadcrumbs.mjs
Normal file
213
scripts/remove-breadcrumbs.mjs
Normal 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
18
scripts/scan-entries.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { scanProjectEntries, writeEntriesManifestAtomic } from '../vite-plugins/utils/entriesManifestCore.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const projectRoot = path.resolve(__dirname, '..');
|
||||
const manifest = scanProjectEntries(projectRoot, ['prototypes', 'themes']);
|
||||
const written = writeEntriesManifestAtomic(projectRoot, manifest);
|
||||
|
||||
console.log(
|
||||
'Generated entries.json (schema v2) with',
|
||||
Object.keys(written.js || {}).length,
|
||||
'js entries and',
|
||||
Object.keys(written.html || {}).length,
|
||||
'html entries (using unified template)',
|
||||
);
|
||||
144
scripts/shot-form-workbench-redesign.mjs
Normal file
144
scripts/shot-form-workbench-redesign.mjs
Normal 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);
|
||||
});
|
||||
246
scripts/shot-form-workbench-themes.mjs
Normal file
246
scripts/shot-form-workbench-themes.mjs
Normal 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);
|
||||
});
|
||||
140
scripts/shot-lease-contract-combo.mjs
Normal file
140
scripts/shot-lease-contract-combo.mjs
Normal 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);
|
||||
});
|
||||
166
scripts/shot-lease-contract-redesign.mjs
Normal file
166
scripts/shot-lease-contract-redesign.mjs
Normal 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);
|
||||
});
|
||||
206
scripts/shot-lease-contract-themes.mjs
Normal file
206
scripts/shot-lease-contract-themes.mjs
Normal 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);
|
||||
});
|
||||
70
scripts/smoke-preview-routes.mjs
Normal file
70
scripts/smoke-preview-routes.mjs
Normal 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;
|
||||
}
|
||||
220
scripts/snapshot-prototypes-legacy.mjs
Normal file
220
scripts/snapshot-prototypes-legacy.mjs
Normal 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();
|
||||
114
scripts/stitch-converter.mjs
Normal file
114
scripts/stitch-converter.mjs
Normal file
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
function normalizeSlashes(input) {
|
||||
return String(input || '').replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
function sanitizeName(rawName) {
|
||||
return String(rawName || '')
|
||||
.replace(/[^a-z0-9-]/gi, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = [...argv];
|
||||
const projectDirArg = args.shift();
|
||||
const outputNameArg = args.shift();
|
||||
let projectRoot = process.cwd();
|
||||
let outputBaseDir = '';
|
||||
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
if (arg === '--project-root') {
|
||||
projectRoot = path.resolve(args[index + 1] || projectRoot);
|
||||
index += 1;
|
||||
} else if (arg === '--output-base-dir') {
|
||||
outputBaseDir = path.resolve(args[index + 1] || '');
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (!projectDirArg) throw new Error('Missing Stitch directory');
|
||||
const outputName = sanitizeName(outputNameArg || path.basename(projectDirArg));
|
||||
if (!outputName) throw new Error('Missing valid output name');
|
||||
return {
|
||||
stitchDir: path.resolve(projectRoot, projectDirArg),
|
||||
outputName,
|
||||
projectRoot,
|
||||
outputBaseDir: outputBaseDir || path.resolve(projectRoot, 'src/prototypes'),
|
||||
};
|
||||
}
|
||||
|
||||
function extractBody(html) {
|
||||
const match = html.match(/<body[^>]*>([\s\S]*?)<\/body>/iu);
|
||||
return match?.[1]?.trim() || html;
|
||||
}
|
||||
|
||||
function hasPendingLogic(html) {
|
||||
return /<script\b|on[A-Z]?\w+=/iu.test(html);
|
||||
}
|
||||
|
||||
function escapeTemplate(value) {
|
||||
return String(value).replace(/`/g, '\\`').replace(/\$\{/g, '\\${');
|
||||
}
|
||||
|
||||
function findCodeHtml(stitchDir) {
|
||||
const direct = path.join(stitchDir, 'code.html');
|
||||
if (fs.existsSync(direct)) return direct;
|
||||
for (const entry of fs.readdirSync(stitchDir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const candidate = path.join(stitchDir, entry.name, 'code.html');
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
}
|
||||
throw new Error('未找到有效的 Stitch 项目结构');
|
||||
}
|
||||
|
||||
function main() {
|
||||
const parsed = parseArgs(process.argv.slice(2));
|
||||
const codePath = findCodeHtml(parsed.stitchDir);
|
||||
const html = fs.readFileSync(codePath, 'utf8');
|
||||
const outputDir = path.join(parsed.outputBaseDir, parsed.outputName);
|
||||
fs.rmSync(outputDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
const body = extractBody(html);
|
||||
fs.writeFileSync(path.join(outputDir, 'index.tsx'), [
|
||||
"import React from 'react';",
|
||||
"import './style.css';",
|
||||
'',
|
||||
'export default function StitchPrototype() {',
|
||||
' return <div className="stitch-import" dangerouslySetInnerHTML={{ __html: markup }} />;',
|
||||
'}',
|
||||
'',
|
||||
`const markup = \`${escapeTemplate(body)}\`;`,
|
||||
'',
|
||||
].join('\n'), 'utf8');
|
||||
fs.writeFileSync(path.join(outputDir, 'style.css'), '.stitch-import { min-height: 100%; }\n', 'utf8');
|
||||
const requiresAi = hasPendingLogic(html);
|
||||
const prompt = requiresAi
|
||||
? [
|
||||
'Google Stitch 页面已导入完成,但检测到脚本或事件逻辑需要 AI 继续完善。',
|
||||
'',
|
||||
`请读取输出目录:\`${normalizeSlashes(path.relative(parsed.projectRoot, outputDir))}/\``,
|
||||
'请先参考 `rules/development-guide.md` 和 `rules/design-guide.md`,再完善交互与动态逻辑。',
|
||||
].join('\n')
|
||||
: null;
|
||||
console.log(JSON.stringify({
|
||||
success: true,
|
||||
outputDir,
|
||||
requiresAi,
|
||||
prompt,
|
||||
reasons: requiresAi ? ['检测到脚本或内联事件逻辑'] : [],
|
||||
}));
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
console.error(error?.message || String(error));
|
||||
process.exit(1);
|
||||
}
|
||||
92
scripts/strip-prototype-nav-from-directory.mjs
Normal file
92
scripts/strip-prototype-nav-from-directory.mjs
Normal 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();
|
||||
268
scripts/subset-beginner-guide-fonts.mjs
Normal file
268
scripts/subset-beginner-guide-fonts.mjs
Normal file
@@ -0,0 +1,268 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { TextDecoder } from 'node:util';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const DEFAULT_APP_ROOT = path.resolve(__dirname, '..');
|
||||
|
||||
const SOURCE_FILES = [
|
||||
'index.tsx',
|
||||
'style.css',
|
||||
'index.html',
|
||||
'README.md',
|
||||
];
|
||||
|
||||
const EXTRA_CODE_POINTS = [
|
||||
0x00a0,
|
||||
0x2013,
|
||||
0x2014,
|
||||
0x2018,
|
||||
0x2019,
|
||||
0x201c,
|
||||
0x201d,
|
||||
0x2022,
|
||||
0x2026,
|
||||
0x3000,
|
||||
0x3001,
|
||||
0x3002,
|
||||
0x300a,
|
||||
0x300b,
|
||||
0x300c,
|
||||
0x300d,
|
||||
0xff01,
|
||||
0xff08,
|
||||
0xff09,
|
||||
0xff0c,
|
||||
0xff1a,
|
||||
0xff1b,
|
||||
0xff1f,
|
||||
];
|
||||
|
||||
const ASCII_FALLBACK =
|
||||
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' +
|
||||
' ~`!@#$%^&*()-_=+[]{}\\|;:\'",.<>/?';
|
||||
|
||||
function uniqueCharacters(input) {
|
||||
const seen = new Set();
|
||||
const output = [];
|
||||
for (const character of input) {
|
||||
if (!seen.has(character)) {
|
||||
seen.add(character);
|
||||
output.push(character);
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
export function parseSubsetArgs(argv = process.argv) {
|
||||
const args = {
|
||||
appRoot: DEFAULT_APP_ROOT,
|
||||
dryRun: false,
|
||||
sourceFontDir: process.env.AXHUB_BEGINNER_GUIDE_FONT_SOURCE_DIR || '',
|
||||
};
|
||||
|
||||
const values = argv.slice(2);
|
||||
for (let index = 0; index < values.length; index += 1) {
|
||||
const value = values[index];
|
||||
if (value === '--dry-run') {
|
||||
args.dryRun = true;
|
||||
continue;
|
||||
}
|
||||
if (value === '--app-root') {
|
||||
args.appRoot = path.resolve(values[index + 1] || '');
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (value === '--source-font-dir') {
|
||||
args.sourceFontDir = path.resolve(values[index + 1] || '');
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (value === '--help' || value === '-h') {
|
||||
args.help = true;
|
||||
continue;
|
||||
}
|
||||
throw new Error(`Unknown option: ${value}`);
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
export function resolveBeginnerGuidePaths(options = {}) {
|
||||
const appRoot = path.resolve(options.appRoot || DEFAULT_APP_ROOT);
|
||||
const prototypeRoot = path.join(appRoot, 'src/prototypes/beginner-guide');
|
||||
const localSourceFontDir = path.join(appRoot, '.local/font-sources/beginner-guide');
|
||||
const sourceFontDir = options.sourceFontDir
|
||||
? path.resolve(options.sourceFontDir)
|
||||
: fs.existsSync(localSourceFontDir)
|
||||
? localSourceFontDir
|
||||
: prototypeRoot;
|
||||
|
||||
return {
|
||||
appRoot,
|
||||
prototypeRoot,
|
||||
sourceFontDir,
|
||||
};
|
||||
}
|
||||
|
||||
export function collectCharactersFromFiles(filePaths) {
|
||||
const content = filePaths
|
||||
.filter((filePath) => fs.existsSync(filePath))
|
||||
.map((filePath) => fs.readFileSync(filePath, 'utf8'))
|
||||
.join('\n');
|
||||
|
||||
return uniqueCharacters(content);
|
||||
}
|
||||
|
||||
export function getGb2312LevelOneCharacters() {
|
||||
const decoder = new TextDecoder('gb18030');
|
||||
const characters = [];
|
||||
|
||||
for (let high = 0xb0; high <= 0xd7; high += 1) {
|
||||
for (let low = 0xa1; low <= 0xfe; low += 1) {
|
||||
characters.push(decoder.decode(Buffer.from([high, low])));
|
||||
}
|
||||
}
|
||||
|
||||
return uniqueCharacters(characters).join('');
|
||||
}
|
||||
|
||||
export function collectSubsetCharacters(options = {}) {
|
||||
const pageCharacters = Array.isArray(options.pageCharacters)
|
||||
? options.pageCharacters
|
||||
: [...String(options.pageCharacters || '')];
|
||||
const commonCharacters = String(
|
||||
options.commonCharacters ?? getGb2312LevelOneCharacters(),
|
||||
);
|
||||
const extraCharacters = String(
|
||||
options.extraCharacters
|
||||
?? `${ASCII_FALLBACK}${String.fromCodePoint(...EXTRA_CODE_POINTS)}`,
|
||||
);
|
||||
|
||||
return uniqueCharacters(`${commonCharacters}${extraCharacters}${pageCharacters.join('')}`);
|
||||
}
|
||||
|
||||
export function getFontJobs(paths) {
|
||||
return [
|
||||
{
|
||||
weight: 400,
|
||||
inputPath: path.join(paths.sourceFontDir, 'TsangerJinKai02-W04.ttf'),
|
||||
outputPath: path.join(paths.prototypeRoot, 'TsangerJinKai02-W04.subset.woff2'),
|
||||
},
|
||||
{
|
||||
weight: 500,
|
||||
inputPath: path.join(paths.sourceFontDir, 'TsangerJinKai02-W05.ttf'),
|
||||
outputPath: path.join(paths.prototypeRoot, 'TsangerJinKai02-W05.subset.woff2'),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function getSourceFilePaths(paths) {
|
||||
return SOURCE_FILES.map((fileName) => path.join(paths.prototypeRoot, fileName));
|
||||
}
|
||||
|
||||
function formatSize(byteLength) {
|
||||
if (byteLength < 1024) return `${byteLength} B`;
|
||||
if (byteLength < 1024 * 1024) return `${(byteLength / 1024).toFixed(1)} KB`;
|
||||
return `${(byteLength / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`Usage: node scripts/subset-beginner-guide-fonts.mjs [options]
|
||||
|
||||
Options:
|
||||
--dry-run Print the plan without writing fonts.
|
||||
--app-root <path> Client app root. Defaults to the current client.
|
||||
--source-font-dir <path> Directory containing TsangerJinKai02-W04.ttf and W05.ttf.
|
||||
|
||||
Environment:
|
||||
AXHUB_BEGINNER_GUIDE_FONT_SOURCE_DIR can provide the source font directory.
|
||||
`);
|
||||
}
|
||||
|
||||
export async function subsetBeginnerGuideFonts(options = {}) {
|
||||
const paths = resolveBeginnerGuidePaths(options);
|
||||
const sourceFilePaths = getSourceFilePaths(paths);
|
||||
const pageCharacters = collectCharactersFromFiles(sourceFilePaths);
|
||||
const subsetCharacters = collectSubsetCharacters({ pageCharacters });
|
||||
const jobs = getFontJobs(paths);
|
||||
|
||||
if (options.dryRun) {
|
||||
return {
|
||||
paths,
|
||||
jobs,
|
||||
characterCount: subsetCharacters.length,
|
||||
written: [],
|
||||
};
|
||||
}
|
||||
|
||||
const missingInputs = jobs
|
||||
.map((job) => job.inputPath)
|
||||
.filter((inputPath) => !fs.existsSync(inputPath));
|
||||
if (missingInputs.length > 0) {
|
||||
throw new Error(
|
||||
[
|
||||
'Missing source font file(s):',
|
||||
...missingInputs.map((inputPath) => ` - ${inputPath}`),
|
||||
'Pass --source-font-dir or set AXHUB_BEGINNER_GUIDE_FONT_SOURCE_DIR.',
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
const { default: subsetFont } = await import('subset-font');
|
||||
fs.mkdirSync(paths.prototypeRoot, { recursive: true });
|
||||
|
||||
const written = [];
|
||||
for (const job of jobs) {
|
||||
const inputBuffer = fs.readFileSync(job.inputPath);
|
||||
const outputBuffer = await subsetFont(inputBuffer, subsetCharacters.join(''), {
|
||||
targetFormat: 'woff2',
|
||||
preserveNameIds: [1, 2, 4, 6],
|
||||
});
|
||||
fs.writeFileSync(job.outputPath, outputBuffer);
|
||||
written.push({
|
||||
...job,
|
||||
inputSize: inputBuffer.byteLength,
|
||||
outputSize: outputBuffer.byteLength,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
paths,
|
||||
jobs,
|
||||
characterCount: subsetCharacters.length,
|
||||
written,
|
||||
};
|
||||
}
|
||||
|
||||
async function runCli() {
|
||||
const args = parseSubsetArgs(process.argv);
|
||||
if (args.help) {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await subsetBeginnerGuideFonts(args);
|
||||
console.log(`Beginner guide subset characters: ${result.characterCount}`);
|
||||
for (const job of result.jobs) {
|
||||
if (args.dryRun) {
|
||||
console.log(`dry-run ${job.weight}: ${job.inputPath} -> ${job.outputPath}`);
|
||||
continue;
|
||||
}
|
||||
const written = result.written.find((item) => item.weight === job.weight);
|
||||
console.log(
|
||||
`${job.weight}: ${formatSize(written.inputSize)} -> ${formatSize(written.outputSize)} ${written.outputPath}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === __filename) {
|
||||
runCli().catch((error) => {
|
||||
console.error(error.message || error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
89
scripts/subset-beginner-guide-fonts.test.mjs
Normal file
89
scripts/subset-beginner-guide-fonts.test.mjs
Normal file
@@ -0,0 +1,89 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
collectCharactersFromFiles,
|
||||
collectSubsetCharacters,
|
||||
getFontJobs,
|
||||
parseSubsetArgs,
|
||||
resolveBeginnerGuidePaths,
|
||||
} from './subset-beginner-guide-fonts.mjs';
|
||||
|
||||
describe('beginner guide font subsetting', () => {
|
||||
it('collects unique page characters while ignoring duplicate text', () => {
|
||||
const fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), 'beginner-font-subset-'));
|
||||
const sourcePath = path.join(fixtureDir, 'index.tsx');
|
||||
fs.writeFileSync(sourcePath, '<h1>发布到云端服务</h1><p>发布到云端服务 ABC 123</p>');
|
||||
|
||||
const characters = collectCharactersFromFiles([sourcePath]);
|
||||
|
||||
expect(characters).toContain('发');
|
||||
expect(characters).toContain('务');
|
||||
expect(characters).toContain('A');
|
||||
expect(characters.filter((character) => character === '发')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('merges page text with a reusable common Chinese fallback set', () => {
|
||||
const characters = collectSubsetCharacters({
|
||||
pageCharacters: ['专', '属', 'A'],
|
||||
commonCharacters: '的一是专',
|
||||
extraCharacters: ' A',
|
||||
});
|
||||
|
||||
expect(characters.join('')).toContain('专');
|
||||
expect(characters.join('')).toContain('的');
|
||||
expect(characters.join('')).toContain('A');
|
||||
expect(characters.filter((character) => character === '专')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('builds one subsetting job for each TsangerJinKai02 source weight', () => {
|
||||
const appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'beginner-font-app-'));
|
||||
const prototypeRoot = path.join(appRoot, 'src/prototypes/beginner-guide');
|
||||
fs.mkdirSync(prototypeRoot, { recursive: true });
|
||||
|
||||
const paths = resolveBeginnerGuidePaths({ appRoot });
|
||||
const jobs = getFontJobs(paths);
|
||||
|
||||
expect(jobs).toEqual([
|
||||
{
|
||||
weight: 400,
|
||||
inputPath: path.join(prototypeRoot, 'TsangerJinKai02-W04.ttf'),
|
||||
outputPath: path.join(prototypeRoot, 'TsangerJinKai02-W04.subset.woff2'),
|
||||
},
|
||||
{
|
||||
weight: 500,
|
||||
inputPath: path.join(prototypeRoot, 'TsangerJinKai02-W05.ttf'),
|
||||
outputPath: path.join(prototypeRoot, 'TsangerJinKai02-W05.subset.woff2'),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('prefers ignored local source fonts over published prototype files', () => {
|
||||
const appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'beginner-font-local-'));
|
||||
const localSourceRoot = path.join(appRoot, '.local/font-sources/beginner-guide');
|
||||
fs.mkdirSync(localSourceRoot, { recursive: true });
|
||||
|
||||
const paths = resolveBeginnerGuidePaths({ appRoot });
|
||||
|
||||
expect(paths.sourceFontDir).toBe(localSourceRoot);
|
||||
});
|
||||
|
||||
it('parses script options for dry runs and explicit source font directories', () => {
|
||||
const args = parseSubsetArgs([
|
||||
'node',
|
||||
'subset-beginner-guide-fonts.mjs',
|
||||
'--dry-run',
|
||||
'--source-font-dir',
|
||||
'/tmp/fonts',
|
||||
'--app-root',
|
||||
'/tmp/app',
|
||||
]);
|
||||
|
||||
expect(args.dryRun).toBe(true);
|
||||
expect(args.sourceFontDir).toBe('/tmp/fonts');
|
||||
expect(args.appRoot).toBe('/tmp/app');
|
||||
});
|
||||
});
|
||||
355
scripts/sync-oneos-web-prototypes.mjs
Normal file
355
scripts/sync-oneos-web-prototypes.mjs
Normal 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
39
scripts/sync-project-metadata.d.ts
vendored
Normal 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;
|
||||
};
|
||||
651
scripts/sync-project-metadata.mjs
Normal file
651
scripts/sync-project-metadata.mjs
Normal 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
39
scripts/sync-project-metadata.mjs.d.ts
vendored
Normal 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;
|
||||
};
|
||||
160
scripts/sync-project-prototype-directory.mjs
Normal file
160
scripts/sync-project-prototype-directory.mjs
Normal 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();
|
||||
358
scripts/sync-prototype-nav-registry.mjs
Normal file
358
scripts/sync-prototype-nav-registry.mjs
Normal 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();
|
||||
}
|
||||
53
scripts/sync-vendor-if-present.mjs
Normal file
53
scripts/sync-vendor-if-present.mjs
Normal file
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { runCommandSync } from './utils/command-runtime.mjs';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const clientRoot = path.resolve(__dirname, '..');
|
||||
|
||||
function findWorkspaceRoot(startDir) {
|
||||
let current = path.resolve(startDir);
|
||||
while (true) {
|
||||
if (fs.existsSync(path.join(current, 'pnpm-workspace.yaml'))) {
|
||||
return current;
|
||||
}
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) {
|
||||
return null;
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function hasMakePackage(workspaceRoot) {
|
||||
try {
|
||||
const packageJson = JSON.parse(fs.readFileSync(path.join(workspaceRoot, 'apps', 'axhub-make', 'package.json'), 'utf8'));
|
||||
return packageJson?.name === '@axhub/make';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const workspaceRoot = findWorkspaceRoot(clientRoot);
|
||||
if (!workspaceRoot || !hasMakePackage(workspaceRoot)) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const result = runCommandSync({
|
||||
command: 'pnpm',
|
||||
args: ['--filter', '@axhub/make', 'vendor:sync'],
|
||||
cwd: workspaceRoot,
|
||||
timeoutMs: 10 * 60 * 1000,
|
||||
maxBuffer: 20 * 1024 * 1024,
|
||||
});
|
||||
|
||||
if (result.status !== 0) {
|
||||
const stderr = result.stderr.trim();
|
||||
const stdout = result.stdout.trim();
|
||||
process.stderr.write(`${stderr || stdout || 'Failed to sync Make vendor packages'}\n`);
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
132
scripts/sync-xll-nav-menu.mjs
Normal file
132
scripts/sync-xll-nav-menu.mjs
Normal 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();
|
||||
}
|
||||
593
scripts/templates/empty-canvas.code-manifest.json
Normal file
593
scripts/templates/empty-canvas.code-manifest.json
Normal file
@@ -0,0 +1,593 @@
|
||||
{
|
||||
"command": "inspect",
|
||||
"generatedAt": "2026-04-01T10:21:53.845Z",
|
||||
"figPath": "scripts/templates/empty-canvas.fig",
|
||||
"archive": {
|
||||
"prelude": "fig-make",
|
||||
"version": 101,
|
||||
"parts": 2
|
||||
},
|
||||
"sourceRoot": "src",
|
||||
"summary": {
|
||||
"totalCodeFiles": 63,
|
||||
"pathCounts": {
|
||||
"(root)": 2,
|
||||
"components": 5,
|
||||
"components/figma": 1,
|
||||
"components/mockups": 5,
|
||||
"components/ui": 48,
|
||||
"guidelines": 1,
|
||||
"styles": 1
|
||||
},
|
||||
"duplicateGroups": []
|
||||
},
|
||||
"entries": [
|
||||
{
|
||||
"nodeChangeIndex": 7,
|
||||
"name": "App.tsx",
|
||||
"codeFilePath": null,
|
||||
"logicalPath": "App.tsx",
|
||||
"sourceCodeSha1": "08dbcc7fa8dfe3f4d85267eaee09a1b86ce3ab3e",
|
||||
"isDuplicate": false,
|
||||
"duplicateCount": 1
|
||||
},
|
||||
{
|
||||
"nodeChangeIndex": 9,
|
||||
"name": "accordion.tsx",
|
||||
"codeFilePath": "components/ui",
|
||||
"logicalPath": "components/ui/accordion.tsx",
|
||||
"sourceCodeSha1": "96e2ea4a76d985018b49b3f26c18bd060992ba1c",
|
||||
"isDuplicate": false,
|
||||
"duplicateCount": 1
|
||||
},
|
||||
{
|
||||
"nodeChangeIndex": 10,
|
||||
"name": "alert-dialog.tsx",
|
||||
"codeFilePath": "components/ui",
|
||||
"logicalPath": "components/ui/alert-dialog.tsx",
|
||||
"sourceCodeSha1": "4499b93fee3f76e8a2cfee3e04ad9acc14e914f8",
|
||||
"isDuplicate": false,
|
||||
"duplicateCount": 1
|
||||
},
|
||||
{
|
||||
"nodeChangeIndex": 11,
|
||||
"name": "alert.tsx",
|
||||
"codeFilePath": "components/ui",
|
||||
"logicalPath": "components/ui/alert.tsx",
|
||||
"sourceCodeSha1": "c4c43c93ca441a0cc96160fcbddc367b42bd0785",
|
||||
"isDuplicate": false,
|
||||
"duplicateCount": 1
|
||||
},
|
||||
{
|
||||
"nodeChangeIndex": 12,
|
||||
"name": "aspect-ratio.tsx",
|
||||
"codeFilePath": "components/ui",
|
||||
"logicalPath": "components/ui/aspect-ratio.tsx",
|
||||
"sourceCodeSha1": "da69e8483774b6cb06c9f1109f6c5481846fa373",
|
||||
"isDuplicate": false,
|
||||
"duplicateCount": 1
|
||||
},
|
||||
{
|
||||
"nodeChangeIndex": 13,
|
||||
"name": "avatar.tsx",
|
||||
"codeFilePath": "components/ui",
|
||||
"logicalPath": "components/ui/avatar.tsx",
|
||||
"sourceCodeSha1": "acca40a2a8a7b9e8b16d56e34722622875ce9279",
|
||||
"isDuplicate": false,
|
||||
"duplicateCount": 1
|
||||
},
|
||||
{
|
||||
"nodeChangeIndex": 14,
|
||||
"name": "badge.tsx",
|
||||
"codeFilePath": "components/ui",
|
||||
"logicalPath": "components/ui/badge.tsx",
|
||||
"sourceCodeSha1": "2bed0297b71935fb53c36665a93713017b4efba0",
|
||||
"isDuplicate": false,
|
||||
"duplicateCount": 1
|
||||
},
|
||||
{
|
||||
"nodeChangeIndex": 15,
|
||||
"name": "breadcrumb.tsx",
|
||||
"codeFilePath": "components/ui",
|
||||
"logicalPath": "components/ui/breadcrumb.tsx",
|
||||
"sourceCodeSha1": "2607a19685b58acec5df7cf79cf3f319860f10d0",
|
||||
"isDuplicate": false,
|
||||
"duplicateCount": 1
|
||||
},
|
||||
{
|
||||
"nodeChangeIndex": 16,
|
||||
"name": "button.tsx",
|
||||
"codeFilePath": "components/ui",
|
||||
"logicalPath": "components/ui/button.tsx",
|
||||
"sourceCodeSha1": "8cc739da9862b0c46a941f2c2d3c04e88ed14f6e",
|
||||
"isDuplicate": false,
|
||||
"duplicateCount": 1
|
||||
},
|
||||
{
|
||||
"nodeChangeIndex": 17,
|
||||
"name": "calendar.tsx",
|
||||
"codeFilePath": "components/ui",
|
||||
"logicalPath": "components/ui/calendar.tsx",
|
||||
"sourceCodeSha1": "dcf6f206c549acd3d3d93938af700040cf4736b3",
|
||||
"isDuplicate": false,
|
||||
"duplicateCount": 1
|
||||
},
|
||||
{
|
||||
"nodeChangeIndex": 18,
|
||||
"name": "card.tsx",
|
||||
"codeFilePath": "components/ui",
|
||||
"logicalPath": "components/ui/card.tsx",
|
||||
"sourceCodeSha1": "368b7c30660e2357f967712258eb04a947d105e6",
|
||||
"isDuplicate": false,
|
||||
"duplicateCount": 1
|
||||
},
|
||||
{
|
||||
"nodeChangeIndex": 19,
|
||||
"name": "carousel.tsx",
|
||||
"codeFilePath": "components/ui",
|
||||
"logicalPath": "components/ui/carousel.tsx",
|
||||
"sourceCodeSha1": "b18b2ad961ae8df8504cd08af7049da628cfcc84",
|
||||
"isDuplicate": false,
|
||||
"duplicateCount": 1
|
||||
},
|
||||
{
|
||||
"nodeChangeIndex": 20,
|
||||
"name": "chart.tsx",
|
||||
"codeFilePath": "components/ui",
|
||||
"logicalPath": "components/ui/chart.tsx",
|
||||
"sourceCodeSha1": "e2fa2b6b71b5b989b07d655d0311b0b656370868",
|
||||
"isDuplicate": false,
|
||||
"duplicateCount": 1
|
||||
},
|
||||
{
|
||||
"nodeChangeIndex": 21,
|
||||
"name": "checkbox.tsx",
|
||||
"codeFilePath": "components/ui",
|
||||
"logicalPath": "components/ui/checkbox.tsx",
|
||||
"sourceCodeSha1": "031623c953f24511c2b364736dc0c29bbd72b0da",
|
||||
"isDuplicate": false,
|
||||
"duplicateCount": 1
|
||||
},
|
||||
{
|
||||
"nodeChangeIndex": 22,
|
||||
"name": "collapsible.tsx",
|
||||
"codeFilePath": "components/ui",
|
||||
"logicalPath": "components/ui/collapsible.tsx",
|
||||
"sourceCodeSha1": "c4a50eb76c7aa18863f1be0fcfb77a39c555dea5",
|
||||
"isDuplicate": false,
|
||||
"duplicateCount": 1
|
||||
},
|
||||
{
|
||||
"nodeChangeIndex": 23,
|
||||
"name": "command.tsx",
|
||||
"codeFilePath": "components/ui",
|
||||
"logicalPath": "components/ui/command.tsx",
|
||||
"sourceCodeSha1": "e2bacd22ead0ead7c35ae4ea81cc063648be5551",
|
||||
"isDuplicate": false,
|
||||
"duplicateCount": 1
|
||||
},
|
||||
{
|
||||
"nodeChangeIndex": 24,
|
||||
"name": "context-menu.tsx",
|
||||
"codeFilePath": "components/ui",
|
||||
"logicalPath": "components/ui/context-menu.tsx",
|
||||
"sourceCodeSha1": "dcc0f2c734146df4fdda300fb88fc4b778a8e943",
|
||||
"isDuplicate": false,
|
||||
"duplicateCount": 1
|
||||
},
|
||||
{
|
||||
"nodeChangeIndex": 25,
|
||||
"name": "dialog.tsx",
|
||||
"codeFilePath": "components/ui",
|
||||
"logicalPath": "components/ui/dialog.tsx",
|
||||
"sourceCodeSha1": "d2e2d438b7ec8f4655cb363bff0b51c362403679",
|
||||
"isDuplicate": false,
|
||||
"duplicateCount": 1
|
||||
},
|
||||
{
|
||||
"nodeChangeIndex": 26,
|
||||
"name": "drawer.tsx",
|
||||
"codeFilePath": "components/ui",
|
||||
"logicalPath": "components/ui/drawer.tsx",
|
||||
"sourceCodeSha1": "77956c0b7663cbe3de836e37395bc58bd441e662",
|
||||
"isDuplicate": false,
|
||||
"duplicateCount": 1
|
||||
},
|
||||
{
|
||||
"nodeChangeIndex": 27,
|
||||
"name": "dropdown-menu.tsx",
|
||||
"codeFilePath": "components/ui",
|
||||
"logicalPath": "components/ui/dropdown-menu.tsx",
|
||||
"sourceCodeSha1": "f261713be8c0469a202d6571e667dced43443dc1",
|
||||
"isDuplicate": false,
|
||||
"duplicateCount": 1
|
||||
},
|
||||
{
|
||||
"nodeChangeIndex": 28,
|
||||
"name": "form.tsx",
|
||||
"codeFilePath": "components/ui",
|
||||
"logicalPath": "components/ui/form.tsx",
|
||||
"sourceCodeSha1": "b35ebd81d98a9f3a1ab62fba289883715f1d1d21",
|
||||
"isDuplicate": false,
|
||||
"duplicateCount": 1
|
||||
},
|
||||
{
|
||||
"nodeChangeIndex": 29,
|
||||
"name": "hover-card.tsx",
|
||||
"codeFilePath": "components/ui",
|
||||
"logicalPath": "components/ui/hover-card.tsx",
|
||||
"sourceCodeSha1": "b72c3f65947cd7593c1a89a354fa1d6c338f36e9",
|
||||
"isDuplicate": false,
|
||||
"duplicateCount": 1
|
||||
},
|
||||
{
|
||||
"nodeChangeIndex": 30,
|
||||
"name": "input-otp.tsx",
|
||||
"codeFilePath": "components/ui",
|
||||
"logicalPath": "components/ui/input-otp.tsx",
|
||||
"sourceCodeSha1": "71c2917cc4fe8bb4c80b11323909cf710a93d0c6",
|
||||
"isDuplicate": false,
|
||||
"duplicateCount": 1
|
||||
},
|
||||
{
|
||||
"nodeChangeIndex": 31,
|
||||
"name": "input.tsx",
|
||||
"codeFilePath": "components/ui",
|
||||
"logicalPath": "components/ui/input.tsx",
|
||||
"sourceCodeSha1": "e9f0dfef25dba4b04ad97609243106533bc078dc",
|
||||
"isDuplicate": false,
|
||||
"duplicateCount": 1
|
||||
},
|
||||
{
|
||||
"nodeChangeIndex": 32,
|
||||
"name": "label.tsx",
|
||||
"codeFilePath": "components/ui",
|
||||
"logicalPath": "components/ui/label.tsx",
|
||||
"sourceCodeSha1": "8c069a29a97703512a9c930d5c4014476834cc96",
|
||||
"isDuplicate": false,
|
||||
"duplicateCount": 1
|
||||
},
|
||||
{
|
||||
"nodeChangeIndex": 33,
|
||||
"name": "menubar.tsx",
|
||||
"codeFilePath": "components/ui",
|
||||
"logicalPath": "components/ui/menubar.tsx",
|
||||
"sourceCodeSha1": "ea4c2aa51da161eeb2298dfbc5e70308022214e7",
|
||||
"isDuplicate": false,
|
||||
"duplicateCount": 1
|
||||
},
|
||||
{
|
||||
"nodeChangeIndex": 34,
|
||||
"name": "navigation-menu.tsx",
|
||||
"codeFilePath": "components/ui",
|
||||
"logicalPath": "components/ui/navigation-menu.tsx",
|
||||
"sourceCodeSha1": "0d7f19cd8946a82063e8d98d3709903326db0aad",
|
||||
"isDuplicate": false,
|
||||
"duplicateCount": 1
|
||||
},
|
||||
{
|
||||
"nodeChangeIndex": 35,
|
||||
"name": "pagination.tsx",
|
||||
"codeFilePath": "components/ui",
|
||||
"logicalPath": "components/ui/pagination.tsx",
|
||||
"sourceCodeSha1": "a259b6b07136d542c95ba8bd37d9337376acf65b",
|
||||
"isDuplicate": false,
|
||||
"duplicateCount": 1
|
||||
},
|
||||
{
|
||||
"nodeChangeIndex": 36,
|
||||
"name": "popover.tsx",
|
||||
"codeFilePath": "components/ui",
|
||||
"logicalPath": "components/ui/popover.tsx",
|
||||
"sourceCodeSha1": "762244296d854ac1ba4015cdb7fcbd9db5ef1754",
|
||||
"isDuplicate": false,
|
||||
"duplicateCount": 1
|
||||
},
|
||||
{
|
||||
"nodeChangeIndex": 37,
|
||||
"name": "progress.tsx",
|
||||
"codeFilePath": "components/ui",
|
||||
"logicalPath": "components/ui/progress.tsx",
|
||||
"sourceCodeSha1": "1a41de261cc00b1eb94b36077854ad9ed3c18804",
|
||||
"isDuplicate": false,
|
||||
"duplicateCount": 1
|
||||
},
|
||||
{
|
||||
"nodeChangeIndex": 38,
|
||||
"name": "radio-group.tsx",
|
||||
"codeFilePath": "components/ui",
|
||||
"logicalPath": "components/ui/radio-group.tsx",
|
||||
"sourceCodeSha1": "650cb3abc42c9a1664e27e7b47eb09b76c6a4e0d",
|
||||
"isDuplicate": false,
|
||||
"duplicateCount": 1
|
||||
},
|
||||
{
|
||||
"nodeChangeIndex": 39,
|
||||
"name": "resizable.tsx",
|
||||
"codeFilePath": "components/ui",
|
||||
"logicalPath": "components/ui/resizable.tsx",
|
||||
"sourceCodeSha1": "588fb45baae2adbcec07d32a0e59476878621ca7",
|
||||
"isDuplicate": false,
|
||||
"duplicateCount": 1
|
||||
},
|
||||
{
|
||||
"nodeChangeIndex": 40,
|
||||
"name": "scroll-area.tsx",
|
||||
"codeFilePath": "components/ui",
|
||||
"logicalPath": "components/ui/scroll-area.tsx",
|
||||
"sourceCodeSha1": "a86380f1aed911b34917005ba4f9949642910dbf",
|
||||
"isDuplicate": false,
|
||||
"duplicateCount": 1
|
||||
},
|
||||
{
|
||||
"nodeChangeIndex": 41,
|
||||
"name": "select.tsx",
|
||||
"codeFilePath": "components/ui",
|
||||
"logicalPath": "components/ui/select.tsx",
|
||||
"sourceCodeSha1": "37fa9c4ceda7950664c385c9ef27ba1bbee47bde",
|
||||
"isDuplicate": false,
|
||||
"duplicateCount": 1
|
||||
},
|
||||
{
|
||||
"nodeChangeIndex": 42,
|
||||
"name": "separator.tsx",
|
||||
"codeFilePath": "components/ui",
|
||||
"logicalPath": "components/ui/separator.tsx",
|
||||
"sourceCodeSha1": "979129951eb27258e96545382eaa9e37b9de1209",
|
||||
"isDuplicate": false,
|
||||
"duplicateCount": 1
|
||||
},
|
||||
{
|
||||
"nodeChangeIndex": 43,
|
||||
"name": "sheet.tsx",
|
||||
"codeFilePath": "components/ui",
|
||||
"logicalPath": "components/ui/sheet.tsx",
|
||||
"sourceCodeSha1": "4f25f42b08ca91c04c4510cc16eb020a9c7f9587",
|
||||
"isDuplicate": false,
|
||||
"duplicateCount": 1
|
||||
},
|
||||
{
|
||||
"nodeChangeIndex": 44,
|
||||
"name": "sidebar.tsx",
|
||||
"codeFilePath": "components/ui",
|
||||
"logicalPath": "components/ui/sidebar.tsx",
|
||||
"sourceCodeSha1": "f79dfb3dbeef2d211510e47f9ac70f5cf33f37cd",
|
||||
"isDuplicate": false,
|
||||
"duplicateCount": 1
|
||||
},
|
||||
{
|
||||
"nodeChangeIndex": 45,
|
||||
"name": "skeleton.tsx",
|
||||
"codeFilePath": "components/ui",
|
||||
"logicalPath": "components/ui/skeleton.tsx",
|
||||
"sourceCodeSha1": "0662ecc7ab4b6c1746d91e009ef88d219005448d",
|
||||
"isDuplicate": false,
|
||||
"duplicateCount": 1
|
||||
},
|
||||
{
|
||||
"nodeChangeIndex": 46,
|
||||
"name": "slider.tsx",
|
||||
"codeFilePath": "components/ui",
|
||||
"logicalPath": "components/ui/slider.tsx",
|
||||
"sourceCodeSha1": "23b80e2e9a0da874dcf78e56feb54346fe2bd0bb",
|
||||
"isDuplicate": false,
|
||||
"duplicateCount": 1
|
||||
},
|
||||
{
|
||||
"nodeChangeIndex": 47,
|
||||
"name": "sonner.tsx",
|
||||
"codeFilePath": "components/ui",
|
||||
"logicalPath": "components/ui/sonner.tsx",
|
||||
"sourceCodeSha1": "72058ff9b056b79f72f79dfbbcf2db164a79e4ab",
|
||||
"isDuplicate": false,
|
||||
"duplicateCount": 1
|
||||
},
|
||||
{
|
||||
"nodeChangeIndex": 48,
|
||||
"name": "switch.tsx",
|
||||
"codeFilePath": "components/ui",
|
||||
"logicalPath": "components/ui/switch.tsx",
|
||||
"sourceCodeSha1": "f83deec5e477bd7ca727eea401354d83ff22f6b5",
|
||||
"isDuplicate": false,
|
||||
"duplicateCount": 1
|
||||
},
|
||||
{
|
||||
"nodeChangeIndex": 49,
|
||||
"name": "table.tsx",
|
||||
"codeFilePath": "components/ui",
|
||||
"logicalPath": "components/ui/table.tsx",
|
||||
"sourceCodeSha1": "ee1282461b1b247aa28520bb5cd6ce716ac1b468",
|
||||
"isDuplicate": false,
|
||||
"duplicateCount": 1
|
||||
},
|
||||
{
|
||||
"nodeChangeIndex": 50,
|
||||
"name": "tabs.tsx",
|
||||
"codeFilePath": "components/ui",
|
||||
"logicalPath": "components/ui/tabs.tsx",
|
||||
"sourceCodeSha1": "40469be2dacfc3fc86909433bafd1d92bf7616d6",
|
||||
"isDuplicate": false,
|
||||
"duplicateCount": 1
|
||||
},
|
||||
{
|
||||
"nodeChangeIndex": 51,
|
||||
"name": "textarea.tsx",
|
||||
"codeFilePath": "components/ui",
|
||||
"logicalPath": "components/ui/textarea.tsx",
|
||||
"sourceCodeSha1": "d608dfd62677493c7361908d510ef7d42a49c212",
|
||||
"isDuplicate": false,
|
||||
"duplicateCount": 1
|
||||
},
|
||||
{
|
||||
"nodeChangeIndex": 52,
|
||||
"name": "toggle-group.tsx",
|
||||
"codeFilePath": "components/ui",
|
||||
"logicalPath": "components/ui/toggle-group.tsx",
|
||||
"sourceCodeSha1": "7b49c924ad02e9ffc94a0be4fab7f92caab54552",
|
||||
"isDuplicate": false,
|
||||
"duplicateCount": 1
|
||||
},
|
||||
{
|
||||
"nodeChangeIndex": 53,
|
||||
"name": "toggle.tsx",
|
||||
"codeFilePath": "components/ui",
|
||||
"logicalPath": "components/ui/toggle.tsx",
|
||||
"sourceCodeSha1": "82d3925cf6ee63d4092d366e01ad478ad7d2f325",
|
||||
"isDuplicate": false,
|
||||
"duplicateCount": 1
|
||||
},
|
||||
{
|
||||
"nodeChangeIndex": 54,
|
||||
"name": "tooltip.tsx",
|
||||
"codeFilePath": "components/ui",
|
||||
"logicalPath": "components/ui/tooltip.tsx",
|
||||
"sourceCodeSha1": "8e78e17fad045f62a35dabc021fb7477849ac93a",
|
||||
"isDuplicate": false,
|
||||
"duplicateCount": 1
|
||||
},
|
||||
{
|
||||
"nodeChangeIndex": 55,
|
||||
"name": "use-mobile.ts",
|
||||
"codeFilePath": "components/ui",
|
||||
"logicalPath": "components/ui/use-mobile.ts",
|
||||
"sourceCodeSha1": "b1102c4af2fef644861e5df108eb5b133dc71805",
|
||||
"isDuplicate": false,
|
||||
"duplicateCount": 1
|
||||
},
|
||||
{
|
||||
"nodeChangeIndex": 56,
|
||||
"name": "utils.ts",
|
||||
"codeFilePath": "components/ui",
|
||||
"logicalPath": "components/ui/utils.ts",
|
||||
"sourceCodeSha1": "f095b349a6d6cbcbf7bcabdb2f51b3095e7403f0",
|
||||
"isDuplicate": false,
|
||||
"duplicateCount": 1
|
||||
},
|
||||
{
|
||||
"nodeChangeIndex": 57,
|
||||
"name": "ImageWithFallback.tsx",
|
||||
"codeFilePath": "components/figma",
|
||||
"logicalPath": "components/figma/ImageWithFallback.tsx",
|
||||
"sourceCodeSha1": "e93040f24666348383e937031a95b102570d13ec",
|
||||
"isDuplicate": false,
|
||||
"duplicateCount": 1
|
||||
},
|
||||
{
|
||||
"nodeChangeIndex": 58,
|
||||
"name": "globals.css",
|
||||
"codeFilePath": "styles",
|
||||
"logicalPath": "styles/globals.css",
|
||||
"sourceCodeSha1": "70abb71fd97a750fb4ba7c1b892f2b70d8d611d5",
|
||||
"isDuplicate": false,
|
||||
"duplicateCount": 1
|
||||
},
|
||||
{
|
||||
"nodeChangeIndex": 59,
|
||||
"name": "Guidelines.md",
|
||||
"codeFilePath": "guidelines",
|
||||
"logicalPath": "guidelines/Guidelines.md",
|
||||
"sourceCodeSha1": "11f6b0e620fa939a50999dada1539096198d9a3b",
|
||||
"isDuplicate": false,
|
||||
"duplicateCount": 1
|
||||
},
|
||||
{
|
||||
"nodeChangeIndex": 60,
|
||||
"name": "Dashboard.tsx",
|
||||
"codeFilePath": "components",
|
||||
"logicalPath": "components/Dashboard.tsx",
|
||||
"sourceCodeSha1": "9f8d3c852c0752919d2f1725be3c902902b20971",
|
||||
"isDuplicate": false,
|
||||
"duplicateCount": 1
|
||||
},
|
||||
{
|
||||
"nodeChangeIndex": 61,
|
||||
"name": "AddExpense.tsx",
|
||||
"codeFilePath": "components",
|
||||
"logicalPath": "components/AddExpense.tsx",
|
||||
"sourceCodeSha1": "dcc8ea2bcab75e0c954784d40e8cbfb2e2ee74b7",
|
||||
"isDuplicate": false,
|
||||
"duplicateCount": 1
|
||||
},
|
||||
{
|
||||
"nodeChangeIndex": 62,
|
||||
"name": "Analytics.tsx",
|
||||
"codeFilePath": "components",
|
||||
"logicalPath": "components/Analytics.tsx",
|
||||
"sourceCodeSha1": "961d99d46e2e7c2b475a77783a098bd48efc27b8",
|
||||
"isDuplicate": false,
|
||||
"duplicateCount": 1
|
||||
},
|
||||
{
|
||||
"nodeChangeIndex": 63,
|
||||
"name": "SearchFilter.tsx",
|
||||
"codeFilePath": "components",
|
||||
"logicalPath": "components/SearchFilter.tsx",
|
||||
"sourceCodeSha1": "7dd9d547b69d19e3390564e6387039bc067891af",
|
||||
"isDuplicate": false,
|
||||
"duplicateCount": 1
|
||||
},
|
||||
{
|
||||
"nodeChangeIndex": 64,
|
||||
"name": "SettingsProfile.tsx",
|
||||
"codeFilePath": "components",
|
||||
"logicalPath": "components/SettingsProfile.tsx",
|
||||
"sourceCodeSha1": "806456c50af5a3f7d302b75a21d9d784f59b4f7d",
|
||||
"isDuplicate": false,
|
||||
"duplicateCount": 1
|
||||
},
|
||||
{
|
||||
"nodeChangeIndex": 65,
|
||||
"name": "Attributions.md",
|
||||
"codeFilePath": null,
|
||||
"logicalPath": "Attributions.md",
|
||||
"sourceCodeSha1": "4f53c7de5eb7d707eea56961544b22433e1a86a3",
|
||||
"isDuplicate": false,
|
||||
"duplicateCount": 1
|
||||
},
|
||||
{
|
||||
"nodeChangeIndex": 67,
|
||||
"name": "DashboardMockup.tsx",
|
||||
"codeFilePath": "components/mockups",
|
||||
"logicalPath": "components/mockups/DashboardMockup.tsx",
|
||||
"sourceCodeSha1": "61b29d59129500144886e0cc403f491d49eac3e5",
|
||||
"isDuplicate": false,
|
||||
"duplicateCount": 1
|
||||
},
|
||||
{
|
||||
"nodeChangeIndex": 68,
|
||||
"name": "AddExpenseMockup.tsx",
|
||||
"codeFilePath": "components/mockups",
|
||||
"logicalPath": "components/mockups/AddExpenseMockup.tsx",
|
||||
"sourceCodeSha1": "e5d430c751a626680cf8bca5411234bb9dfd8adb",
|
||||
"isDuplicate": false,
|
||||
"duplicateCount": 1
|
||||
},
|
||||
{
|
||||
"nodeChangeIndex": 69,
|
||||
"name": "AnalyticsMockup.tsx",
|
||||
"codeFilePath": "components/mockups",
|
||||
"logicalPath": "components/mockups/AnalyticsMockup.tsx",
|
||||
"sourceCodeSha1": "f94a844fbe540becde9c6314c04867d13b94461c",
|
||||
"isDuplicate": false,
|
||||
"duplicateCount": 1
|
||||
},
|
||||
{
|
||||
"nodeChangeIndex": 70,
|
||||
"name": "SearchMockup.tsx",
|
||||
"codeFilePath": "components/mockups",
|
||||
"logicalPath": "components/mockups/SearchMockup.tsx",
|
||||
"sourceCodeSha1": "05442b617000ddd07d81961858d1bcd2b13c7509",
|
||||
"isDuplicate": false,
|
||||
"duplicateCount": 1
|
||||
},
|
||||
{
|
||||
"nodeChangeIndex": 71,
|
||||
"name": "SettingsMockup.tsx",
|
||||
"codeFilePath": "components/mockups",
|
||||
"logicalPath": "components/mockups/SettingsMockup.tsx",
|
||||
"sourceCodeSha1": "7a92a34c001a2c6be42031acf36eb657ce3810de",
|
||||
"isDuplicate": false,
|
||||
"duplicateCount": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
scripts/templates/empty-canvas.fig
Normal file
BIN
scripts/templates/empty-canvas.fig
Normal file
Binary file not shown.
63
scripts/utils/command-runtime.d.ts
vendored
Normal file
63
scripts/utils/command-runtime.d.ts
vendored
Normal file
@@ -0,0 +1,63 @@
|
||||
export type DecodeOutputOptions = {
|
||||
platform?: NodeJS.Platform;
|
||||
};
|
||||
|
||||
export type RunCommandOptions = {
|
||||
command: string;
|
||||
args?: string[];
|
||||
cwd?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
timeoutMs?: number;
|
||||
detached?: boolean;
|
||||
capture?: boolean;
|
||||
stdio?: any;
|
||||
};
|
||||
|
||||
export type RunCommandResult = {
|
||||
command: string;
|
||||
args: string[];
|
||||
spawnCommand: string;
|
||||
spawnArgs: string[];
|
||||
code: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
stdoutBuffer: Buffer;
|
||||
stderrBuffer: Buffer;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
};
|
||||
|
||||
export type RunCommandSyncOptions = {
|
||||
command: string;
|
||||
args?: string[];
|
||||
cwd?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
timeoutMs?: number;
|
||||
maxBuffer?: number;
|
||||
};
|
||||
|
||||
export type RunCommandSyncResult = {
|
||||
command: string;
|
||||
args: string[];
|
||||
spawnCommand: string;
|
||||
spawnArgs: string[];
|
||||
status: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
error: Error | null;
|
||||
stdoutBuffer: Buffer;
|
||||
stderrBuffer: Buffer;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
};
|
||||
|
||||
export function decodeOutput(value: unknown, options?: DecodeOutputOptions): string;
|
||||
export function runCommand(options: RunCommandOptions): Promise<RunCommandResult>;
|
||||
export function runCommandSync(options: RunCommandSyncOptions): RunCommandSyncResult;
|
||||
export function commandExists(command: string): boolean;
|
||||
export function getPreferredNpmCommand(): string;
|
||||
export function getPreferredNpxCommand(): string;
|
||||
export function getSpawnCommandSpec(command: string, args?: string[], platform?: NodeJS.Platform): {
|
||||
command: string;
|
||||
args: string[];
|
||||
windowsHide: boolean;
|
||||
};
|
||||
export function __resetWindowsCodePageCacheForTests(): void;
|
||||
340
scripts/utils/command-runtime.mjs
Normal file
340
scripts/utils/command-runtime.mjs
Normal file
@@ -0,0 +1,340 @@
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import iconv from 'iconv-lite';
|
||||
|
||||
const WINDOWS_CODEPAGE_TIMEOUT_MS = 1200;
|
||||
let cachedWindowsCodePage = null;
|
||||
|
||||
function getPlatform(overridePlatform) {
|
||||
return overridePlatform || process.platform;
|
||||
}
|
||||
|
||||
function quoteForCmdExec(value) {
|
||||
if (!value) return '""';
|
||||
if (!/[\s"&^|<>]/.test(value)) return value;
|
||||
const escaped = String(value)
|
||||
.replace(/(\\*)"/g, '$1$1\\"')
|
||||
.replace(/(\\+)$/g, '$1$1');
|
||||
return `"${escaped}"`;
|
||||
}
|
||||
|
||||
function buildWindowsCommandLine(command, args) {
|
||||
return [command, ...args].map((part) => quoteForCmdExec(String(part))).join(' ');
|
||||
}
|
||||
|
||||
function getEnvValue(env, key) {
|
||||
if (!env) return undefined;
|
||||
|
||||
const direct = env[key];
|
||||
if (typeof direct === 'string' && direct.length > 0) {
|
||||
return direct;
|
||||
}
|
||||
|
||||
const matchedKey = Object.keys(env).find((candidate) => candidate.toLowerCase() === key.toLowerCase());
|
||||
if (!matchedKey) return undefined;
|
||||
|
||||
const value = env[matchedKey];
|
||||
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function getWindowsPathExtList(env) {
|
||||
const pathExt = getEnvValue(env, 'PATHEXT') || '.COM;.EXE;.BAT;.CMD';
|
||||
return pathExt
|
||||
.split(';')
|
||||
.map((ext) => ext.trim())
|
||||
.filter(Boolean)
|
||||
.map((ext) => (ext.startsWith('.') ? ext.toLowerCase() : `.${ext.toLowerCase()}`));
|
||||
}
|
||||
|
||||
function resolveWindowsCommand(command, env) {
|
||||
if (!command || typeof command !== 'string') return command;
|
||||
|
||||
const trimmed = command.trim();
|
||||
if (!trimmed) return trimmed;
|
||||
|
||||
const hasPathSeparator = /[\\/]/.test(trimmed);
|
||||
const ext = path.extname(trimmed);
|
||||
const pathExts = ext ? [''] : getWindowsPathExtList(env);
|
||||
|
||||
const candidateDirs = hasPathSeparator
|
||||
? ['']
|
||||
: (getEnvValue(env, 'PATH') || '')
|
||||
.split(';')
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const baseCandidates = hasPathSeparator ? [trimmed] : candidateDirs.map((dir) => path.join(dir, trimmed));
|
||||
|
||||
for (const baseCandidate of baseCandidates) {
|
||||
const suffixes = ext ? [''] : pathExts;
|
||||
for (const suffix of suffixes) {
|
||||
const fullPath = suffix ? `${baseCandidate}${suffix}` : baseCandidate;
|
||||
if (fs.existsSync(fullPath)) {
|
||||
return fullPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function shouldUseWindowsCmdWrapper(platform, command) {
|
||||
if (platform !== 'win32') return false;
|
||||
return /\.(cmd|bat)$/i.test(command) || !/\.(exe|com)$/i.test(command);
|
||||
}
|
||||
|
||||
function getSpawnSpec(command, args, platform = process.platform, env = process.env) {
|
||||
const resolvedCommand = platform === 'win32' ? resolveWindowsCommand(command, env) : command;
|
||||
|
||||
if (!shouldUseWindowsCmdWrapper(platform, resolvedCommand)) {
|
||||
return {
|
||||
command: resolvedCommand,
|
||||
args,
|
||||
windowsHide: platform === 'win32',
|
||||
};
|
||||
}
|
||||
|
||||
const commandLine = buildWindowsCommandLine(resolvedCommand, args);
|
||||
return {
|
||||
command: 'cmd.exe',
|
||||
args: ['/d', '/s', '/c', commandLine],
|
||||
windowsHide: true,
|
||||
};
|
||||
}
|
||||
|
||||
function mapCodePageToEncoding(codePage) {
|
||||
if (codePage === 65001) return 'utf8';
|
||||
if (codePage === 936) return 'gbk';
|
||||
if (codePage === 54936) return 'gb18030';
|
||||
return 'gb18030';
|
||||
}
|
||||
|
||||
function parseWindowsCodePage(text) {
|
||||
if (!text) return null;
|
||||
const match = String(text).match(/(\d{3,5})/);
|
||||
if (!match) return null;
|
||||
const codePage = Number(match[1]);
|
||||
return Number.isFinite(codePage) ? codePage : null;
|
||||
}
|
||||
|
||||
function readWindowsCodePageSync() {
|
||||
if (cachedWindowsCodePage !== null) {
|
||||
return cachedWindowsCodePage;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = spawnSync('cmd.exe', ['/d', '/s', '/c', 'chcp'], {
|
||||
windowsHide: true,
|
||||
encoding: 'utf8',
|
||||
timeout: WINDOWS_CODEPAGE_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
const output = `${result.stdout || ''}\n${result.stderr || ''}`;
|
||||
cachedWindowsCodePage = parseWindowsCodePage(output);
|
||||
} catch {
|
||||
cachedWindowsCodePage = null;
|
||||
}
|
||||
|
||||
return cachedWindowsCodePage;
|
||||
}
|
||||
|
||||
function toBuffer(value) {
|
||||
if (!value) return Buffer.alloc(0);
|
||||
if (Buffer.isBuffer(value)) return value;
|
||||
if (typeof value === 'string') return Buffer.from(value);
|
||||
if (value instanceof Uint8Array) return Buffer.from(value);
|
||||
return Buffer.from(String(value));
|
||||
}
|
||||
|
||||
export function decodeOutput(value, options = {}) {
|
||||
if (value === null || value === undefined) return '';
|
||||
if (typeof value === 'string') return value;
|
||||
|
||||
const platform = getPlatform(options.platform);
|
||||
const buffer = toBuffer(value);
|
||||
if (buffer.length === 0) return '';
|
||||
|
||||
try {
|
||||
const strictUtf8 = new TextDecoder('utf-8', { fatal: true }).decode(buffer);
|
||||
return strictUtf8;
|
||||
} catch {
|
||||
// Fall through to platform fallback decoder.
|
||||
}
|
||||
|
||||
if (platform === 'win32') {
|
||||
const activeCodePage = readWindowsCodePageSync();
|
||||
const preferredEncoding = mapCodePageToEncoding(activeCodePage);
|
||||
|
||||
try {
|
||||
return iconv.decode(buffer, preferredEncoding);
|
||||
} catch {
|
||||
// Fall through to generic fallback.
|
||||
}
|
||||
|
||||
try {
|
||||
return iconv.decode(buffer, 'gb18030');
|
||||
} catch {
|
||||
// Fall through to latin1 fallback.
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return buffer.toString('utf8');
|
||||
} catch {
|
||||
return buffer.toString('latin1');
|
||||
}
|
||||
}
|
||||
|
||||
export function runCommandSync(options) {
|
||||
const {
|
||||
command,
|
||||
args = [],
|
||||
cwd,
|
||||
env,
|
||||
timeoutMs,
|
||||
maxBuffer,
|
||||
} = options;
|
||||
|
||||
const platform = process.platform;
|
||||
const mergedEnv = env ? { ...process.env, ...env } : process.env;
|
||||
const spawnSpec = getSpawnSpec(command, args, platform, mergedEnv);
|
||||
const result = spawnSync(spawnSpec.command, spawnSpec.args, {
|
||||
cwd,
|
||||
env: mergedEnv,
|
||||
timeout: timeoutMs,
|
||||
maxBuffer,
|
||||
windowsHide: spawnSpec.windowsHide,
|
||||
encoding: null,
|
||||
});
|
||||
|
||||
const stdoutBuffer = toBuffer(result.stdout);
|
||||
const stderrBuffer = toBuffer(result.stderr);
|
||||
|
||||
return {
|
||||
command,
|
||||
args,
|
||||
spawnCommand: spawnSpec.command,
|
||||
spawnArgs: spawnSpec.args,
|
||||
status: typeof result.status === 'number' ? result.status : null,
|
||||
signal: result.signal || null,
|
||||
error: result.error || null,
|
||||
stdoutBuffer,
|
||||
stderrBuffer,
|
||||
stdout: decodeOutput(stdoutBuffer, { platform }),
|
||||
stderr: decodeOutput(stderrBuffer, { platform }),
|
||||
};
|
||||
}
|
||||
|
||||
export function runCommand(options) {
|
||||
const {
|
||||
command,
|
||||
args = [],
|
||||
cwd,
|
||||
env,
|
||||
timeoutMs,
|
||||
detached = false,
|
||||
capture = true,
|
||||
stdio,
|
||||
} = options;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const platform = process.platform;
|
||||
const mergedEnv = env ? { ...process.env, ...env } : process.env;
|
||||
const spawnSpec = getSpawnSpec(command, args, platform, mergedEnv);
|
||||
|
||||
const child = spawn(spawnSpec.command, spawnSpec.args, {
|
||||
cwd,
|
||||
env: mergedEnv,
|
||||
detached,
|
||||
stdio: stdio || (capture ? ['ignore', 'pipe', 'pipe'] : 'inherit'),
|
||||
windowsHide: spawnSpec.windowsHide,
|
||||
});
|
||||
|
||||
const stdoutChunks = [];
|
||||
const stderrChunks = [];
|
||||
|
||||
if (capture && child.stdout) {
|
||||
child.stdout.on('data', (chunk) => {
|
||||
stdoutChunks.push(toBuffer(chunk));
|
||||
});
|
||||
}
|
||||
|
||||
if (capture && child.stderr) {
|
||||
child.stderr.on('data', (chunk) => {
|
||||
stderrChunks.push(toBuffer(chunk));
|
||||
});
|
||||
}
|
||||
|
||||
let timeoutId = null;
|
||||
if (typeof timeoutMs === 'number' && timeoutMs > 0) {
|
||||
timeoutId = setTimeout(() => {
|
||||
child.kill('SIGTERM');
|
||||
}, timeoutMs);
|
||||
}
|
||||
|
||||
child.once('error', (error) => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
reject(error);
|
||||
});
|
||||
|
||||
child.once('close', (code, signal) => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
|
||||
const stdoutBuffer = Buffer.concat(stdoutChunks);
|
||||
const stderrBuffer = Buffer.concat(stderrChunks);
|
||||
|
||||
resolve({
|
||||
command,
|
||||
args,
|
||||
spawnCommand: spawnSpec.command,
|
||||
spawnArgs: spawnSpec.args,
|
||||
code: typeof code === 'number' ? code : null,
|
||||
signal: signal || null,
|
||||
stdoutBuffer,
|
||||
stderrBuffer,
|
||||
stdout: decodeOutput(stdoutBuffer, { platform }),
|
||||
stderr: decodeOutput(stderrBuffer, { platform }),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function commandExists(command) {
|
||||
if (!command || typeof command !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const checker = process.platform === 'win32' ? 'where' : 'which';
|
||||
const result = runCommandSync({
|
||||
command: checker,
|
||||
args: [command],
|
||||
timeoutMs: 2000,
|
||||
});
|
||||
|
||||
return result.status === 0;
|
||||
}
|
||||
|
||||
export function getPreferredNpmCommand() {
|
||||
if (process.platform !== 'win32') {
|
||||
return 'npm';
|
||||
}
|
||||
|
||||
return commandExists('npm.cmd') ? 'npm.cmd' : 'npm';
|
||||
}
|
||||
|
||||
export function getPreferredNpxCommand() {
|
||||
if (process.platform !== 'win32') {
|
||||
return 'npx';
|
||||
}
|
||||
|
||||
return commandExists('npx.cmd') ? 'npx.cmd' : 'npx';
|
||||
}
|
||||
|
||||
export function getSpawnCommandSpec(command, args = [], platform = process.platform) {
|
||||
return getSpawnSpec(command, args, platform);
|
||||
}
|
||||
|
||||
export function __resetWindowsCodePageCacheForTests() {
|
||||
cachedWindowsCodePage = null;
|
||||
}
|
||||
41
scripts/utils/generatedTsxValidator.mjs
Normal file
41
scripts/utils/generatedTsxValidator.mjs
Normal file
@@ -0,0 +1,41 @@
|
||||
import ts from 'typescript';
|
||||
|
||||
function formatDiagnostic(diagnostic) {
|
||||
const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n');
|
||||
if (!diagnostic.file || typeof diagnostic.start !== 'number') {
|
||||
return message;
|
||||
}
|
||||
|
||||
const { line, character } = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start);
|
||||
const sourceLine = diagnostic.file.text.split(/\r?\n/u)[line] || '';
|
||||
return `${diagnostic.file.fileName}:${line + 1}:${character + 1} - ${message}\n${sourceLine}`;
|
||||
}
|
||||
|
||||
export function validateGeneratedTsx(source, filePath = 'generated.tsx') {
|
||||
const result = ts.transpileModule(source, {
|
||||
fileName: filePath,
|
||||
reportDiagnostics: true,
|
||||
compilerOptions: {
|
||||
jsx: ts.JsxEmit.ReactJSX,
|
||||
module: ts.ModuleKind.ESNext,
|
||||
target: ts.ScriptTarget.ESNext,
|
||||
},
|
||||
});
|
||||
|
||||
const diagnostics = (result.diagnostics || []).filter(
|
||||
(diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error,
|
||||
);
|
||||
|
||||
return {
|
||||
ok: diagnostics.length === 0,
|
||||
diagnostics,
|
||||
formatted: diagnostics.map(formatDiagnostic).join('\n\n'),
|
||||
};
|
||||
}
|
||||
|
||||
export function assertValidGeneratedTsx(source, filePath = 'generated.tsx') {
|
||||
const validation = validateGeneratedTsx(source, filePath);
|
||||
if (!validation.ok) {
|
||||
throw new Error(`生成的 TSX 语法校验失败:\n${validation.formatted}`);
|
||||
}
|
||||
}
|
||||
26
scripts/utils/serverInfo.d.ts
vendored
Normal file
26
scripts/utils/serverInfo.d.ts
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
export type AxhubServerRole = 'runtime' | 'admin';
|
||||
|
||||
export interface AxhubServerInfo {
|
||||
pid: number;
|
||||
port: number;
|
||||
host: string;
|
||||
origin: string;
|
||||
projectRoot: string;
|
||||
startedAt: string;
|
||||
}
|
||||
|
||||
export interface ServerInfoPathOptions {
|
||||
homeDir?: string;
|
||||
}
|
||||
|
||||
export function readServerInfo(projectRoot: string, role: AxhubServerRole, options?: ServerInfoPathOptions): AxhubServerInfo | null;
|
||||
export function getAdminServerInfoPath(projectRoot?: string, options?: ServerInfoPathOptions): string;
|
||||
export function getRuntimeServerInfoPath(projectRoot: string): string;
|
||||
export function writeServerInfo(
|
||||
projectRoot: string,
|
||||
role: AxhubServerRole,
|
||||
info: AxhubServerInfo,
|
||||
options?: ServerInfoPathOptions,
|
||||
): AxhubServerInfo;
|
||||
export function fetchHealth(origin: string, timeoutMs?: number): Promise<unknown | null>;
|
||||
export function normalizeHealthServerInfo(data: unknown): AxhubServerInfo | null;
|
||||
108
scripts/utils/serverInfo.mjs
Normal file
108
scripts/utils/serverInfo.mjs
Normal file
@@ -0,0 +1,108 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
const MAKE_STATE_DIR = path.join('.axhub', 'make');
|
||||
const RUNTIME_SERVER_INFO_RELATIVE_PATH = path.join(MAKE_STATE_DIR, '.dev-server-info.json');
|
||||
const ADMIN_SERVER_INFO_RELATIVE_PATH = path.join(MAKE_STATE_DIR, '.admin-server-info.json');
|
||||
const MAKE_HOME_DIR_ENV = 'AXHUB_MAKE_HOME_DIR';
|
||||
|
||||
function resolveProjectRoot(projectRoot) {
|
||||
return path.resolve(projectRoot);
|
||||
}
|
||||
|
||||
function getServerInfoPath(projectRoot, role) {
|
||||
if (role === 'admin') {
|
||||
return getAdminServerInfoPath();
|
||||
}
|
||||
return path.join(resolveProjectRoot(projectRoot), RUNTIME_SERVER_INFO_RELATIVE_PATH);
|
||||
}
|
||||
|
||||
function getGlobalHomeDir(options = {}) {
|
||||
return options.homeDir || process.env[MAKE_HOME_DIR_ENV] || os.homedir();
|
||||
}
|
||||
|
||||
export function getAdminServerInfoPath(_projectRoot, options = {}) {
|
||||
return path.join(path.resolve(getGlobalHomeDir(options)), ADMIN_SERVER_INFO_RELATIVE_PATH);
|
||||
}
|
||||
|
||||
export function getRuntimeServerInfoPath(projectRoot) {
|
||||
return getServerInfoPath(projectRoot, 'runtime');
|
||||
}
|
||||
|
||||
function normalizeServerInfo(data) {
|
||||
if (!data || typeof data !== 'object') {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
typeof data.pid !== 'number'
|
||||
|| typeof data.port !== 'number'
|
||||
|| typeof data.host !== 'string'
|
||||
|| typeof data.origin !== 'string'
|
||||
|| typeof data.projectRoot !== 'string'
|
||||
|| typeof data.startedAt !== 'string'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
pid: data.pid,
|
||||
port: data.port,
|
||||
host: data.host,
|
||||
origin: data.origin,
|
||||
projectRoot: resolveProjectRoot(data.projectRoot),
|
||||
startedAt: data.startedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function readServerInfo(projectRoot, role, options = {}) {
|
||||
const infoPath = role === 'admin'
|
||||
? getAdminServerInfoPath(projectRoot, options)
|
||||
: getServerInfoPath(projectRoot, role);
|
||||
if (!fs.existsSync(infoPath)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return normalizeServerInfo(JSON.parse(fs.readFileSync(infoPath, 'utf8')));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeServerInfo(projectRoot, role, info, options = {}) {
|
||||
const normalized = {
|
||||
...info,
|
||||
projectRoot: resolveProjectRoot(info.projectRoot),
|
||||
};
|
||||
const infoPath = role === 'admin'
|
||||
? getAdminServerInfoPath(projectRoot, options)
|
||||
: getServerInfoPath(projectRoot, role);
|
||||
fs.mkdirSync(path.dirname(infoPath), { recursive: true });
|
||||
fs.writeFileSync(infoPath, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8');
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export async function fetchHealth(origin, timeoutMs = 1000) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await fetch(new URL('/api/health', origin), {
|
||||
signal: controller.signal,
|
||||
headers: { accept: 'application/json' },
|
||||
});
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
return await response.json();
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeHealthServerInfo(data) {
|
||||
if (!data || typeof data !== 'object') {
|
||||
return null;
|
||||
}
|
||||
return normalizeServerInfo(data.server ?? data);
|
||||
}
|
||||
115
scripts/v0-converter.mjs
Normal file
115
scripts/v0-converter.mjs
Normal file
@@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
function normalizeSlashes(input) {
|
||||
return String(input || '').replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
function sanitizeName(rawName) {
|
||||
return String(rawName || '')
|
||||
.replace(/[^a-z0-9-]/gi, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = [...argv];
|
||||
const projectDirArg = args.shift();
|
||||
const outputNameArg = args.shift();
|
||||
let targetType = 'prototypes';
|
||||
let projectRoot = process.cwd();
|
||||
let outputBaseDir = '';
|
||||
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
if (arg === '--target-type') {
|
||||
targetType = String(args[index + 1] || '').trim();
|
||||
index += 1;
|
||||
} else if (arg === '--project-root') {
|
||||
projectRoot = path.resolve(args[index + 1] || projectRoot);
|
||||
index += 1;
|
||||
} else if (arg === '--output-base-dir') {
|
||||
outputBaseDir = path.resolve(args[index + 1] || '');
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (!projectDirArg) throw new Error('Missing project directory');
|
||||
const outputName = sanitizeName(outputNameArg || path.basename(projectDirArg));
|
||||
if (!outputName) throw new Error('Missing valid output name');
|
||||
return {
|
||||
projectDir: path.resolve(projectRoot, projectDirArg),
|
||||
outputName,
|
||||
targetType,
|
||||
projectRoot,
|
||||
outputBaseDir: outputBaseDir || path.resolve(projectRoot, 'src', targetType),
|
||||
};
|
||||
}
|
||||
|
||||
function copyDirectory(src, dest) {
|
||||
if (!fs.existsSync(src)) return 0;
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
let count = 0;
|
||||
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
||||
if (entry.name === 'node_modules' || entry.name === '.next') continue;
|
||||
const srcPath = path.join(src, entry.name);
|
||||
const destPath = path.join(dest, entry.name);
|
||||
if (entry.isDirectory()) count += copyDirectory(srcPath, destPath);
|
||||
else if (entry.isFile()) {
|
||||
fs.copyFileSync(srcPath, destPath);
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function ensureIndex(outputDir) {
|
||||
const indexPath = path.join(outputDir, 'index.tsx');
|
||||
if (!fs.existsSync(indexPath)) {
|
||||
fs.writeFileSync(indexPath, [
|
||||
"import React from 'react';",
|
||||
'',
|
||||
'export default function ImportedV0Prototype() {',
|
||||
' return <div data-import-source="v0">V0 import requires AI conversion.</div>;',
|
||||
'}',
|
||||
'',
|
||||
].join('\n'), 'utf8');
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const parsed = parseArgs(process.argv.slice(2));
|
||||
if (!fs.existsSync(path.join(parsed.projectDir, 'app'))) {
|
||||
throw new Error('这不是一个有效的 V0 项目(缺少 app/ 目录)');
|
||||
}
|
||||
const outputDir = path.join(parsed.outputBaseDir, parsed.outputName);
|
||||
fs.rmSync(outputDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(parsed.outputBaseDir, { recursive: true });
|
||||
const fileCount = copyDirectory(parsed.projectDir, outputDir);
|
||||
ensureIndex(outputDir);
|
||||
const taskPath = path.join(outputDir, parsed.targetType === 'themes' ? '.v0-theme-tasks.md' : '.v0-tasks.md');
|
||||
fs.writeFileSync(taskPath, [
|
||||
'# V0 项目转换任务清单',
|
||||
'',
|
||||
'> 请先阅读 `rules/v0-project-converter.md` 和 `rules/development-guide.md`,再基于该目录完成原型转换。',
|
||||
'',
|
||||
`- 输出目录:\`${normalizeSlashes(path.relative(parsed.projectRoot, outputDir))}/\``,
|
||||
`- 已复制文件数:${fileCount}`,
|
||||
'',
|
||||
].join('\n'), 'utf8');
|
||||
console.log(JSON.stringify({
|
||||
success: true,
|
||||
outputDir,
|
||||
tasksFile: normalizeSlashes(path.relative(parsed.projectRoot, taskPath)),
|
||||
}));
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
console.error(error?.message || String(error));
|
||||
process.exit(1);
|
||||
}
|
||||
Reference in New Issue
Block a user