/* Kole UI —— 部署打包器(.dockerignore 是唯一真源) * * 背景(实测事故): * 远端 /opt/kole-ui 之前靠手工拷贝维护,没有 git、也没有 .dockerignore, * 于是 ① .design_library/kole-ui/specs 等目录随镜像发布并对外可下载; * ② 曾经修好的 nginx.conf(absolute_redirect off)被下一次拷贝覆盖丢失。 * 本脚本把「该发什么、不该发什么」固化为可复现的一步: * - 文件集只包含 docker build 真正需要的顶层条目; * - 排除规则**解析 .dockerignore**(含 Docker 的匹配语义),不再维护第二份清单; * - 硬断言:规范原文/agent-reports/preview/ui_kits 绝不出现,站点运行必需文件必须出现。 * * 用法: * node tools/pack-deploy.mjs # 生成 dist-deploy/kole-ui/ + manifest * node tools/pack-deploy.mjs --tar # 额外打出 dist-deploy/kole-ui-deploy.tar.gz */ 'use strict'; import fs from 'node:fs'; import path from 'node:path'; import { execFileSync } from 'node:child_process'; import { collectPublishSet, TOP_FILES } from './lib/publish-set.mjs'; const ROOT = process.cwd(); const OUT_DIR = path.join(ROOT, 'dist-deploy'); const STAGE = path.join(OUT_DIR, 'kole-ui'); /* 发布集(含 .dockerignore 的解析与匹配语义、内容目录清单、历史版本快照目录)统一在 tools/lib/publish-set.mjs 里,与 tools/verify-deploy-consistency.mjs 同源 —— 「打包器发了什么」与「线上核对看到什么」必须是同一份清单。 */ const { kept, candidates, excludedCount, versionDirs } = collectPublishSet(ROOT); const pcIndex = JSON.parse(fs.readFileSync(path.join(ROOT, '.design_library', 'kole-ui', 'components', 'index.json'), 'utf8')); const pcCount = pcIndex.components.length; const pcFrameworkCount = pcCount * 5; /* ---------- 硬断言:不该发的绝不允许出现 ---------- */ const MUST_ABSENT = [ '.design_library/kole-ui/specs/组件1.txt', '.design_library/kole-ui/agent-reports/phase3-component-modal.json', '.design_library/kole-ui/preview', '.design_library/kole-ui/ui_kits', '.design_library/kole-ui/SKILL.md', /* 移动端规格原文与 PC 规格同级:仓库内部真源,不进镜像 */ '.design_library/kole-ui-mobile/spec', 'AGENTS.md', 'CHANGELOG.md', 'ROADMAP.md', 'TESTING.md', 'PLATFORMS.md', 'docker-compose.yml.bak-20260916', 'build-site.ps1', ]; const MUST_PRESENT = [ 'site/index.html', 'site/app.js', 'site/logger.js', 'site/data.json', 'site/data.js', 'site/tokens/tokens.css', 'site/style.css', 'site/i18n.js', /* 逐示例用法片段(P21):目录留在 data.js,正文按组件懒加载;缺了页面就没有「一个使用场景一块代码」 */ 'site/examples/button.json', 'frameworks/Button.css', 'frameworks/Button.html', 'frameworks/Button.vue3.vue', 'tests/index.html', '.design_library/kole-ui/colors_and_type.css', '.design_library/kole-ui/components.css', '.design_library/kole-ui/css.json', '.design_library/kole-ui/components/button.json', /* 移动端平台:文档站六页(与 PC 同名同序的指南页 + 平台页)+ 自包含数据 + 索引 + 令牌层 + 实现 + 测试页 */ 'site/m/index.html', 'site/m/guide.html', 'site/m/design.html', 'site/m/faq.html', 'site/m/changelog.html', 'site/m/platform.html', 'site/m/style.css', 'site/m/data.mobile.json', 'frameworks-mobile/NavBar.html', 'frameworks-mobile/NavBar.css', 'frameworks-mobile/NavBar.uniapp.vue', 'tests/mobile/index.html', 'tests/mobile/_collect.html', '.design_library/kole-ui-mobile/colors_and_type.css', '.design_library/kole-ui-mobile/components/index.json', /* PC × uni-app 端(试点集) */ 'frameworks-uniapp-pc/Button.uniapp.vue', '.design_library/kole-ui-uniapp/index.json', 'Dockerfile', '.dockerignore', 'nginx.conf', 'docker-compose.yml', 'sitemap.xml', 'versions.json', ]; const problems = []; for (const f of MUST_ABSENT) { const bad = kept.some((k) => k === f || k.startsWith(f + '/')); if (bad) problems.push('不该出现:' + f); } for (const f of MUST_PRESENT) { if (!kept.includes(f)) problems.push('缺失必需文件:' + f); } const shells = kept.filter((f) => /^site\/components\/[^/]+\.html$/.test(f)).length; if (shells !== pcCount) problems.push(`site/components 薄壳数应为 ${pcCount},实际 ${shells}`); /* 历史版本快照必须整份随包(缺壳或缺依赖 → 版本菜单点进去白屏) */ for (const dir of versionDirs) { for (const rel of ['site/index.html', 'site/data.js', 'frameworks/Button.css', '.design_library/kole-ui/colors_and_type.css', 'VERSION.json']) { if (!kept.includes(`${dir}/${rel}`)) problems.push(`版本快照 ${dir} 缺少 ${rel}`); } /* 快照不该带自己的 versions.json:那是归档当时的快照,之后新归档的版本不会进去, 实测会让快照页误判成单版本、角标不可点(回不到最新版)。运行时读部署根那份。 */ if (kept.includes(`${dir}/site/versions.json`)) problems.push(`版本快照 ${dir} 带了过期的 versions.json`); const snapShells = kept.filter((f) => new RegExp(`^${dir}/site/components/[^/]+\\.html$`).test(f)).length; if (snapShells !== 79) problems.push(`版本快照 ${dir} 的组件薄壳数应为 79,实际 ${snapShells}`); } const fw = kept.filter((f) => f.startsWith('frameworks/')).length; if (fw !== pcFrameworkCount) problems.push(`frameworks 实现文件应为 ${pcFrameworkCount},实际 ${fw}`); /* 移动端:组件数从索引读(不写死),实现文件 = 组件数 × 6 端;文档页与测试页各 = 组件数 */ const mobileIndex = JSON.parse( fs.readFileSync(path.join(ROOT, '.design_library', 'kole-ui-mobile', 'components', 'index.json'), 'utf8') ); const mbCount = mobileIndex.components.length; const mbEnds = mobileIndex.ends.length; const fwMobile = kept.filter((f) => f.startsWith('frameworks-mobile/')).length; if (fwMobile !== mbCount * mbEnds) { problems.push(`frameworks-mobile 实现文件应为 ${mbCount * mbEnds}(${mbCount} 组件 × ${mbEnds} 端),实际 ${fwMobile}`); } const mDocs = kept.filter((f) => /^site\/m\/component\/[^/]+\.html$/.test(f)).length; if (mDocs !== mbCount) problems.push(`site/m/component 文档页应为 ${mbCount},实际 ` + mDocs); /* 移动端文档站根页:与 PC 同名同序的指南页(组件总览/快速开始/设计规范/常见问题/更新日志)+ 平台与端 */ const mRootPages = kept.filter((f) => /^site\/m\/(index|guide|design|faq|changelog|platform)\.html$/.test(f)).length; if (mRootPages !== 6) problems.push('site/m 根页应为 6(index/guide/design/faq/changelog/platform),实际 ' + mRootPages); const mTests = kept.filter((f) => /^tests\/mobile\/[^/]+\.html$/.test(f)).length; if (mTests !== mbCount + 4) { problems.push(`tests/mobile 页面应为 ${mbCount + 4}(组件页 + index + _collect + _template + _index_template),实际 ${mTests}`); } const mTestSlugs = kept.filter( (f) => /^tests\/mobile\/[^/]+\.html$/.test(f) && !/^tests\/mobile\/(index|_collect|_template|_index_template)\.html$/.test(f) ).length; if (mTestSlugs !== mbCount) problems.push(`tests/mobile 组件测试页应为 ${mbCount},实际 ` + mTestSlugs); const fwUniappPc = kept.filter((f) => f.startsWith('frameworks-uniapp-pc/')).length; if (fwUniappPc !== 3) problems.push('frameworks-uniapp-pc 试点文件应为 3,实际 ' + fwUniappPc); if (problems.length) { console.error('[pack-deploy] FAIL'); for (const p of problems) console.error(' - ' + p); process.exit(1); } /* ---------- 落盘 ---------- */ fs.rmSync(STAGE, { recursive: true, force: true }); let bytes = 0; for (const rel of kept) { const dst = path.join(STAGE, rel); fs.mkdirSync(path.dirname(dst), { recursive: true }); const buf = fs.readFileSync(path.join(ROOT, rel)); fs.writeFileSync(dst, buf); bytes += buf.length; } const manifest = { generatedAt: new Date().toISOString(), files: kept.length, bytes, components: shells, frameworks: fw, mobile: { impl: fwMobile, docs: mDocs, testPages: mTestSlugs }, uniappPc: fwUniappPc, excludedByDockerignore: excludedCount, list: kept, }; fs.writeFileSync(path.join(OUT_DIR, 'deploy-manifest.json'), JSON.stringify(manifest, null, 2) + '\n'); console.log('[pack-deploy] OK'); console.log(' staging : ' + STAGE); console.log(' files : ' + kept.length + '(内容文件 ' + candidates.length + ',按 .dockerignore 排除 ' + excludedCount + ';部署配置文件 ' + TOP_FILES.length + ' 个始终保留)'); console.log(' bytes : ' + (bytes / 1024).toFixed(1) + ' KB'); console.log(' components : ' + shells + ' 个薄壳 / frameworks ' + fw + ' 个实现文件'); console.log(' mobile : ' + fwMobile + ' 个实现文件(' + mbCount + ' 组件 × 6 端)/ 文档页 ' + mDocs + ' / 测试页 ' + mTestSlugs); console.log(' uniapp-pc : ' + fwUniappPc + ' 个试点 SFC(PC × uni-app)'); console.log(' 断言 : 排除项 ' + MUST_ABSENT.length + ' 条全部未出现;必需项 ' + MUST_PRESENT.length + ' 条全部存在'); if (process.argv.includes('--tar')) { /* 用相对路径调用 tar:bsdtar(Windows 自带)对含中文的绝对路径 + -C 组合会报 status 2 */ const tgzRel = path.join('dist-deploy', 'kole-ui-deploy.tar.gz'); const tgz = path.join(ROOT, tgzRel); fs.rmSync(tgz, { force: true }); execFileSync('tar', ['-czf', tgzRel, '-C', 'dist-deploy', 'kole-ui'], { cwd: ROOT, stdio: 'inherit' }); console.log(' tarball : ' + tgz + '(' + (fs.statSync(tgz).size / 1024).toFixed(1) + ' KB)'); }