#!/usr/bin/env node /** * snapshot-site.mjs — 把当前构建归档成一个可切换的历史版本文档站。 * * 产物布局(站点根必须继续叫 site:SITE_BASE 取 pathname 里第一段 '/site/', * 改名即全站 CSS/data.js 404,见 site/app.js 顶部注释): * * /site/ ← 最新版(根站点,永远指当前构建) * /1.4.1/site/ ← 归档快照(磁盘上就是仓库根的 1.4.1/) * /1.4.1/frameworks/ /1.4.1/.design_library/ ← 该版本的演示页与令牌依赖 * /1.4.1/index.html /1.4.1/sitemap.xml * * 站点根是按 URL 反推的,所以 /1.4.1/site/... 下相对路径(../.design_library、sources/…) * 全部原样成立,快照内不需要改任何路径。切换入口见 site/app.js 的 loadVersionManifest, * 清单由 tools/precompute.mjs 生成(只有磁盘上真存在快照的版本才会入清单)。 * * 用法: * npm run snapshot:site -- 1.4.1 # 需要构建产物已就绪 * npm run snapshot:site -- 1.4.1 --build # 先跑 build-site.ps1 + precompute 再归档 * * 注意:归档的是**当前工作区**的构建。仓库里没有历史源码快照、也没有 git tag, * 所以旧版本无法凭空重建 —— 每次发版后归档一次,才有可切换的历史版本。 */ import { cpSync, existsSync, mkdirSync, readdirSync, rmSync, readFileSync, writeFileSync, statSync } from 'node:fs'; import { execFileSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { dirname, join, basename } from 'node:path'; const ROOT = dirname(dirname(fileURLToPath(import.meta.url))); const SITE_DIR = join(ROOT, 'site'); const argv = process.argv.slice(2); const flags = new Set(argv.filter((a) => a.startsWith('--'))); const version = argv.find((a) => !a.startsWith('--')); const fail = (msg) => { console.error('[snapshot] FAIL: ' + msg); process.exit(1); }; const ok = (msg) => console.log('[snapshot] OK: ' + msg); if (!version) fail('缺少版本号。用法:npm run snapshot:site -- [--build]'); if (!/^\d+\.\d+\.\d+$/.test(version)) fail(`版本号必须是 x.y.z(收到 ${version})`); /* 版本号必须与 CHANGELOG / 构建版本对得上,否则清单里会出现对不上的条目 */ const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')); const changelog = existsSync(join(ROOT, 'CHANGELOG.md')) ? readFileSync(join(ROOT, 'CHANGELOG.md'), 'utf8') : ''; const inChangelog = new RegExp('^## \\[' + version.replace(/\./g, '\\.') + '\\]', 'm').test(changelog); if (!inChangelog) fail(`CHANGELOG.md 里没有 [${version}] 段 —— 先写变更条目(tools/release.mjs --bump 可提升版本)`); if (pkg.version !== version && !flags.has('--force-version')) { fail(`package.json 是 ${pkg.version},与归档版本 ${version} 不一致`); } if (flags.has('--build')) { console.log('[snapshot] 构建站点(build-site.ps1 + precompute)…'); if (process.platform !== 'win32') fail('--build 只能在 Windows 上跑:build-site.ps1 依赖 PowerShell 5.1'); execFileSync('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', 'build-site.ps1'], { cwd: ROOT, stdio: 'inherit' }); execFileSync(process.execPath, [join(ROOT, 'tools', 'precompute.mjs')], { cwd: ROOT, stdio: 'inherit' }); } for (const f of ['index.html', 'app.js', 'data.js', 'data.json', 'style.css']) { if (!existsSync(join(SITE_DIR, f))) fail(`缺少构建产物 site/${f} —— 先运行 npm run build:site`); } const data = JSON.parse(readFileSync(join(SITE_DIR, 'data.json'), 'utf8')); const built = String((data.meta && data.meta.version) || ''); /* 正常情况下归档的就是本次构建的版本。--force-version 只用于补档/演练: 历史版本的源码不在仓库里(没有 git tag、没有旧快照),拿当前构建冒充旧版 只适合验证机制,不适合当正式历史版本发布。 */ if (built !== version && !flags.has('--force-version')) { fail(`构建产物版本是 ${built || '未知'},与归档版本 ${version} 不一致 —— 先 npm run build:site(或加 --build)`); } if (built !== version) { console.warn(`[snapshot] 警告:构建产物是 ${built},却按 ${version} 归档(--force-version)。`); console.warn('[snapshot] 该快照内容是当前构建,仅用于验证机制,不要当作正式历史版本发布。'); } /* 快照落在**仓库根**:URL 是 //site/…,服务器把 / 映射到仓库根(dev-server 的 ROOT、nginx 的 root),所以磁盘上必须是 //site/。 放进 site/ 里会变成 /site//… —— 版本菜单点进去就是 404。 */ const dest = join(ROOT, version); /* 归档 site/ 时排除三类:自身、其它版本目录(否则会把上次快照套进这次), 以及 versions.json —— 版本清单必须只留在站点根:快照自带的那份是归档当时的快照, 之后新归档的版本不会进去,实测会让快照页误判成单版本、角标不可点(回不到最新版)。 */ const skipSelf = (src) => { const rel = src.slice(SITE_DIR.length + 1).replace(/\\/g, '/'); return rel === version || rel === 'versions.json' || /^\d+\.\d+\.\d+(?:\/|$)/.test(rel); }; if (existsSync(dest)) { if (!flags.has('--force')) { fail(`${basename(dest)}/ 已存在。确认要覆盖就加 --force(会先删除该目录重建)`); } rmSync(dest, { recursive: true, force: true }); console.log(`[snapshot] 已删除旧快照 ${version}/`); } mkdirSync(dest, { recursive: true }); /* cpSync 拒绝把目录拷进自己的子目录(Node ≥16.7 的 ERR_FS_CP_EINVAL), 且 site/ 与 dest 是兄弟目录、跨目录搬移可能跨盘 —— 先落到暂存再搬进 dest。 */ const stage = join(ROOT, 'dist-deploy', `.snapshot-stage-${version}`); rmSync(stage, { recursive: true, force: true }); mkdirSync(stage, { recursive: true }); try { cpSync(SITE_DIR, join(stage, 'site'), { recursive: true, filter: (src) => !skipSelf(src) }); cpSync(join(ROOT, 'frameworks'), join(stage, 'frameworks'), { recursive: true }); cpSync(join(ROOT, '.design_library'), join(stage, '.design_library'), { recursive: true, /* 规范原文与 agent 报告不进快照:站点不读它们,且是仓库内部资料(部署包同样排除) */ filter: (src) => !/(?:^|[\\/])(specs|agent-reports|preview|ui_kits)(?:[\\/]|$)/.test(src) }); cpSync(join(ROOT, 'index.html'), join(stage, 'index.html')); if (existsSync(join(ROOT, 'sitemap.xml'))) cpSync(join(ROOT, 'sitemap.xml'), join(stage, 'sitemap.xml')); for (const entry of readdirSync(stage)) { cpSync(join(stage, entry), join(dest, entry), { recursive: true }); } } finally { rmSync(stage, { recursive: true, force: true }); } /* 归档标记:给后续版本核对「这份快照到底是哪个版本、什么时候归档的」 */ const now = new Date(); const localDay = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; writeFileSync( join(dest, 'VERSION.json'), JSON.stringify({ version, archivedAt: localDay, source: 'tools/snapshot-site.mjs', note: 'archive of the working-tree build at the time of release; paths inside are unmodified' }, null, 2) + '\n' ); const size = (p) => { let total = 0; for (const e of readdirSync(p, { withFileTypes: true })) { const f = join(p, e.name); total += e.isDirectory() ? size(f) : statSync(f).size; } return total; }; const mb = (size(dest) / 1024 / 1024).toFixed(1); ok(`已归档 v${version} → ${version}/(${mb} MB,URL /${version}/site/)`); /* 清单必须跟着更新,否则版本菜单里看不到刚归档的版本 */ execFileSync(process.execPath, [join(ROOT, 'tools', 'precompute.mjs')], { cwd: ROOT, stdio: 'inherit' }); const manifest = JSON.parse(readFileSync(join(SITE_DIR, 'versions.json'), 'utf8')); ok(`site/versions.json 现有 ${manifest.versions.length} 个在线版本:${manifest.versions.map((v) => 'v' + v.version).join(' / ')}`); console.log('[snapshot] 下一步:node site/dev-server.js 后打开 http://127.0.0.1:3311/site/ 实测切换;'); console.log('[snapshot] 部署走 tools/pack-deploy.mjs(site/ 目录整体打包,快照随包上线)。');