Files
aurora-admin/tools/verify-versions.mjs
T
aurora-admin f1fbfc2ddb
Regression / regression (push) Canceled after 0s
feat(品牌标识): 几何 K 图标(favicon/顶栏标记/theme-color) + 并行会话成果入库
## 品牌标识(本次会话)

起因:品牌此前没有任何图形标识 —— 唯一 favicon 是内联 data-URI 里的字母「A」,
那是 v2.0.0「Aurora Admin → Kole UI」改名漏掉的一处(PC 顶栏也是「A」,
移动端站已是「K」;移动端文档站则完全没有 favicon)。

- 几何:24 网格三个互不接触的笔画(竖 + 两斜),圆头描边;
  描边 2.25 → 16px 标签页尺寸下正好 1.5px = 规范原文「描边1.5px」
- 取色分两套(刻意):favicon 硬编码品牌蓝/白(渲染在浏览器标签栏,不继承 kole-dark);
  顶栏标记走 currentColor(实测暗色下自动转 rgb(20,22,28))
- 新增 theme-color 双条(light #FFFFFF / dark #1C1F26,取 --kole-color-card-bg)
- 修 site/app.js hero 标语 KOLE ADMIN → KOLE UI(改名变形残留)
- 移动端 7 个模板补 favicon(此前计数 0)

验收:门禁 9 条全 OK(site-routing/site-routes/mobile-docs/mobile-site/isolation/
theme/nav/i18n/icons);PC 回归 1464/1464 · 移动端 807/807,各连跑 8 次一致;
两端 favicon 405 字节逐字节一致;PC 站控制台错误 1→0。

## 并行会话成果(本次一并入库)

- 图标系统:2576 图标(TDesign/Element Plus,MIT)+ 11 端注入 + 5 个构建门禁工具
  + IconPreview 预览页 + ICON-SPEC.md 冻结规格
- 移动端平台:47 组件 × 6 端 + 文档站 53 页 + 隔离门禁
- PC 组件:103 个大后台组件 / 组件11 批次
- uni-app:PC 端试点 + 移动端端实现 + 真实编译验证

## 工程

- .gitignore 补 .scratch/ 与 .zcode-preexisting-*.txt(会话中间产物,实测 9.1MB,不入库)
- CHANGELOG 补品牌标识条目
- ROADMAP 登 S8-P4(品牌标识任务包 + og:image/apple-touch-icon 未做部分)
2026-09-21 10:05:48 +08:00

234 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* verify-versions.mjs — 文档站版本切换的静态门禁(只读,不启动服务)。
*
* 覆盖三件事:
* 1) 清单契约:site/versions.json 的结构、顺序(最新版在首位)、path 形态;
* 2) 入口契约:index.html 的版本控件标记 + app.js 的加载/渲染/绑定实现;
* 3) 快照契约:磁盘上每个 site/<x.y.z>/ 目录都必须是「能独立打开」的站点快照。
*
* 运行时行为(点一下真的跳过去)由 tools/verify-site-routes.mjs 用真浏览器覆盖。
*/
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
import { join } from 'node:path';
const ROOT = join(import.meta.dirname, '..');
const SITE = join(ROOT, 'site');
const failures = [];
const checks = [];
function check(name, ok, detail = '') {
checks.push({ name, ok, detail });
if (!ok) failures.push(`${name}${detail ? `: ${detail}` : ''}`);
}
const app = readFileSync(join(SITE, 'app.js'), 'utf8');
const index = readFileSync(join(SITE, 'index.html'), 'utf8');
const devServer = readFileSync(join(SITE, 'dev-server.js'), 'utf8');
const nginx = readFileSync(join(ROOT, 'nginx.conf'), 'utf8');
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8'));
const manifestPath = join(SITE, 'versions.json');
/* ---------- 1. 清单契约 ---------- */
check('versions.json exists', existsSync(manifestPath), 'run npm run precompute');
let manifest = null;
if (existsSync(manifestPath)) {
try {
manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
} catch (e) {
check('versions.json parses', false, String(e.message).slice(0, 120));
}
}
if (manifest) {
const list = manifest.versions;
check('versions.json has entries', Array.isArray(list) && list.length > 0);
check('siteDir is site', manifest.siteDir === 'site',
`站点根靠 pathname 里第一段 "/site/" 反推,目录名改了会全站 404(实际 ${manifest.siteDir})`);
check('latest flag points at the first entry', !!(list && list[0] && list[0].latest === true));
check('latest field matches the first entry', !!(list && list[0] && manifest.latest === list[0].version));
const badVersion = (list || []).filter((v) => !/^\d+\.\d+\.\d+$/.test(String(v.version || '')));
check('every version is x.y.z', badVersion.length === 0, badVersion.map((v) => v.version).join(', '));
const badPath = (list || []).filter((v) => {
const p = String(v.path || '');
return p !== '..' && !/^\d+\.\d+\.\d+$/.test(p);
});
check('every path is ".." or a snapshot dir', badPath.length === 0, badPath.map((v) => v.path).join(', '));
check('package.json version is in the manifest', (list || []).some((v) => v.version === pkg.version),
`package.json=${pkg.version} vs ${(list || []).map((v) => v.version).join('/')}`);
/* 归档目录必须真在磁盘上(仓库根 /<x.y.z>/site/)—— 清单里出现幽灵版本就是「点进去 404」 */
const ghosts = (list || []).filter((v) => v.path !== '..' && !existsSync(join(ROOT, v.path, 'site', 'index.html')));
check('no ghost versions in the manifest', ghosts.length === 0, ghosts.map((v) => v.version).join(', '));
/* 反向:磁盘上有快照却没入清单(precompute 未重跑) */
const onDisk = readdirSync(ROOT, { withFileTypes: true })
.filter((e) => e.isDirectory() && /^\d+\.\d+\.\d+$/.test(e.name))
.filter((e) => existsSync(join(ROOT, e.name, 'site', 'index.html')))
.map((e) => e.name);
const listed = new Set((list || []).map((v) => String(v.path)));
const unlisted = onDisk.filter((v) => !listed.has(v));
check('every snapshot on disk is listed', unlisted.length === 0,
unlisted.length ? `${unlisted.join(', ')}(重跑 npm run precompute)` : '');
/* 只有当前版本时的形态:单版本必须能干净降级 —— 角标退回纯展示、菜单与窄屏 select 都不出现。
(这里不禁止磁盘上存在快照:以后归档历史版本是正常操作,只要求清单与磁盘一致。) */
const single = list && list.length === 1;
check('single-version manifest is the root site', !single || (list[0].path === '..' && list[0].latest === true),
single ? JSON.stringify(list[0]) : `${list ? list.length : 0} versions`);
}
/* ---------- 2. 入口契约 ---------- */
check('version trigger markup', /id="version-trigger"/.test(index) && /aria-haspopup="listbox"/.test(index));
check('version chip is inside the trigger', /id="version-trigger"[\s\S]{0,400}?id="ver"/.test(index));
check('version menu markup', /id="version-menu"[\s\S]{0,200}?role="listbox"/.test(index));
/* 下拉面板是绝对定位:必须与触发角标同处一个定位容器(.ver-picker:position relative),
否则面板会锚到页面而不是角标下方 —— 实测跑到了左上角。 */
check('version menu has a positioned anchor', /class="ver-picker"[\s\S]{0,300}?id="version-trigger"[\s\S]{0,600}?id="version-menu"/.test(index)
&& /\.ver-picker\s*\{\s*position:\s*relative/.test(readFileSync(join(SITE, 'style.css'), 'utf8')));
check('version menu anchors below the trigger', /\.ver-menu\s*\{[\s\S]{0,300}?top:\s*calc\(100% \+ 8px\)/.test(readFileSync(join(SITE, 'style.css'), 'utf8')));
check('version menu opts use the dedicated classes', /ver-opt-num/.test(app) && /ver-opt-tag/.test(app) && /ver-opt-check/.test(app));
check('version menu starts hidden', /id="version-menu"[^>]*class="[^"]*hidden/.test(index));
check('narrow-screen version select', /id="ver-select-wrap"/.test(index) && /id="ver-select"/.test(index));
check('narrow-screen select starts hidden', /id="ver-select-wrap"[^>]*hidden/.test(index));
/* ≤560px 顶栏放不下版本控件(实测溢出),抽屉是这一档的入口:必须有标记、且默认不显示 */
check('drawer version select markup', /id="drawer-ver"/.test(index) && /id="drawer-ver-select"/.test(index));
check('drawer version select starts hidden', /id="drawer-ver"[^>]*hidden/.test(index));
check('drawer version select shares the option list', /\[select, \$\(['"]drawer-ver-select['"]\)\]/.test(app));
check('manifest loader exists', /function\s+loadVersionManifest\s*\(/.test(app) && /versionsManifestUrl/.test(app));
check('manifest fetched with no-store', /cache:\s*'no-store'/.test(app));
check('menu renderer exists', /function\s+renderVersionMenu\s*\(/.test(app));
check('interaction binding exists', /function\s+bindVersionSelect\s*\(/.test(app) && /bindVersionSelect\(\);/.test(app));
check('version switch preserves the route', /function\s+goVersion\s*\([\s\S]{0,400}?routePath\(\)/.test(app));
/* 路径校验按「行为」验,而不是按写法验。2026-09-20 实测:实现改写成
「sentinel + 严格 semver 白名单」后,原先匹配某种写法的检查误报失败,
而实际行为更强(只放行 '..' 与单段 x.y.z;反斜杠、'//'、多段路径一律拒绝)。
这里把函数体抽出来跑输入矩阵 —— 判据仍是「只有站点根 '..' 与单段版本目录名合法」。 */
const vpFn = app.match(/function\s+isValidVersionPath\s*\(([^)]*)\)\s*\{([\s\S]*?)\n {2}\}/);
let vpOk = false;
let vpDetail = 'isValidVersionPath 未找到';
if (vpFn) {
try {
const sentinel = (app.match(/CURRENT_PATH_SENTINEL\s*=\s*'([^']*)'/) || [])[1] || '..';
const fn = new Function(vpFn[1], 'CURRENT_PATH_SENTINEL', vpFn[2]);
const cases = [
[sentinel, true], ['1.4.1', true], ['10.20.30', true],
['1.4', false], ['latest', false], ['', false],
['1.4.1/x', false], ['x/1.4.1', false], ['../../etc', false],
['1.4.1\\x', false], ['1.4.1//x', false],
];
const bad = cases.filter(([input, want]) => {
let got;
try { got = !!fn(input, sentinel); } catch (e) { got = 'throw:' + e.message; }
return got !== want;
});
vpOk = bad.length === 0;
vpDetail = bad.map(([i, w]) => `${JSON.stringify(i)} 期望 ${w}`).join('; ');
} catch (e) { vpDetail = '抽取函数体失败: ' + e.message; }
}
check('path validation keeps the single-".." rule(按行为)', vpOk, vpDetail);
/* 只有「清单拿不到任何版本」才退化为纯角标(data-single=1 + pointer-events:none);
清单里有版本(哪怕只有一版)就是正常下拉 —— 用户要求下拉始终存在,里面就一项。 */
check('drawer/none-list mode degrades to a plain chip',
/data-single/.test(app) && /versionsState\.interactive\s*=\s*!!\(list\s*&&\s*list\.length\s*>\s*0\)/.test(app)
&& /if \(!list \|\| !list\.length\)/.test(app));
/* 「取不到清单就静默降级」按**行为**判:实现允许多候选(站点根一份 + 快照自带一份,按 rank 定优先级),
但每个 fetch 必须各自吞掉失败、最终以「可用的那份」或 null 回调。
2026-09-20 实测:实现改成多候选后,原先按 `catch(function () { done(null)` 写法匹配的判据误报失败,
而行为完全符合本意(`.catch(...)` 里只留注释 + `.then(settle, settle)`)。
反例(必须仍然失败):删掉那个吞异常的 catch,或把回调改成抛错。 */
check('fetch failure degrades silently(按行为)',
/function\s+loadVersionManifest\s*\([\s\S]{0,1600}?\.catch\(function\s*\(\)\s*\{[\s\S]{0,120}?\}\)/.test(app) &&
/done\((?:best\s*\?\s*best\.list\s*:\s*)?null\)/.test(app));
/* 版本号不得硬编码进 app.js:角标文案只能来自构建数据或清单。
先剥注释再扫:2026-09-20 实测 文档注释里的路由示例(/1.4.1/site/component/button/h5)
被当成硬编码字面量误报 —— 判据本意是「**代码**里不得出现字面量版本号」。
反例(必须仍然失败):代码里出现 var v = 'v2.0.0'; */
const appCode = app.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^[ \t]*\/\/.*$/gm, '');
const hardcoded = [...appCode.matchAll(/['"]v?(\d+\.\d+\.\d+)['"]/g)].map((m) => m[1]);
check('no hardcoded version strings in app.js(已剥注释)', hardcoded.length === 0, hardcoded.join(', '));
/* ---------- 3. 服务器契约 ---------- */
check('dev-server allows snapshot dirs', /VERSION_DIR\s*=\s*\/\^\\d\+\\\.\\d\+\\\.\\d\+\$\//.test(devServer));
check('dev-server falls back to the versioned shell', /spaMatch\[1\]/.test(devServer));
check('dev-server redirects version roots', /verRoot/.test(devServer));
check('nginx has the versioned fallback', /location ~ \^\/\(\[0-9\]\+\\\.\[0-9\]\+\\\.\[0-9\]\+\)\/site\//.test(nginx));
/* 快照缺失时必须是干净的 404,不能是 500。
2026-09-20 实测:本轮发布把已清理的 1.4.1 快照一并移除后,`/1.4.1/site/` 返回 500 ——
容器日志是 "rewrite or internal redirection cycle while internally redirecting to
/1.4.1/site/index.html":try_files 的回落目标又匹配回同一 location,形成内部重定向环。
末位带 `=404` 后,前三个参数退化为文件存在性检查,缺快照即 404。 */
check(
'nginx versioned fallback terminates with =404(缺快照 → 404 而非 500)',
/try_files\s+\$uri\s+\$uri\/\s+\/\$1\/site\/index\.html\s+=404\s*;/.test(nginx),
'期望:try_files $uri $uri/ /$1/site/index.html =404;'
);
check('nginx marks versions.json no-store', /versions\\\.json[\s\S]{0,1200}?no-store/.test(nginx));
/* Dockerfile 必须**逐版本显式** COPY 快照到自己的子目录。
2026-09-20 实测踩到的坑:写成「通配源 + 站点根做目标」时(形如 COPY [0-9]x.[0-9]x.[0-9]x
加斜杠 → /usr/share/nginx/html/),COPY 复制的是目录**内容** → 快照的 site、frameworks、
sitemap.xml 原地合并进站点根,用快照那份旧构建覆盖掉本次构建(镜像里 data.js 是快照那次的
generated、根 frameworks 多出改名前的 21 个小写页),且 /1.4.1/ 目录根本没建出来 →
nginx 版本 location 自循环 → 500。归档新版本时忘了补 COPY 行,同样会 500 ——
故这里双向卡住:磁盘上的每个快照都要有 COPY 行,Dockerfile 里也不许留已删除版本的 COPY 行。 */
const dockerfile = existsSync(join(ROOT, 'Dockerfile')) ? readFileSync(join(ROOT, 'Dockerfile'), 'utf8') : '';
const snapDirs = existsSync(ROOT)
? readdirSync(ROOT, { withFileTypes: true })
.filter((e) => e.isDirectory() && /^\d+\.\d+\.\d+$/.test(e.name))
.map((e) => e.name)
: [];
const copyLines = [...dockerfile.matchAll(/^COPY\s+(\d+\.\d+\.\d+)\s+\/usr\/share\/nginx\/html\/\1\s*$/gm)].map((m) => m[1]);
check('no wildcard snapshot COPY in Dockerfile(通配会把快照内容合并进站点根)',
!/^COPY\s+\[0-9\][^\n]*\s+\/usr\/share\/nginx\/html\/\s*$/m.test(dockerfile));
for (const v of snapDirs) {
check(`Dockerfile copies snapshot ${v} into its own dir`, copyLines.includes(v));
}
const staleCopies = copyLines.filter((v) => !snapDirs.includes(v));
check('no Dockerfile COPY for missing snapshots', staleCopies.length === 0, staleCopies.join(', '));
/* ---------- 4. 快照契约 ---------- */
if (existsSync(manifestPath)) {
for (const v of (manifest && manifest.versions) || []) {
if (v.path === '..') continue;
const base = join(ROOT, v.path);
for (const rel of ['site/index.html', 'site/data.js', 'frameworks', '.design_library/kole-ui/colors_and_type.css', 'VERSION.json']) {
check(`snapshot ${v.version} has ${rel}`, existsSync(join(base, rel)));
}
/* 快照**不带**自己的 versions.json:那份是归档当时写下的,之后新归档的版本不会进去,
实测会让快照页误判成单版本、角标变不可点(回不到最新版)。运行时改为读部署根那份。 */
check(`snapshot ${v.version} carries no stale manifest`, !existsSync(join(base, 'site', 'versions.json')));
const marker = join(base, 'VERSION.json');
if (existsSync(marker)) {
try {
const info = JSON.parse(readFileSync(marker, 'utf8'));
check(`snapshot ${v.version} marker matches`, info.version === v.version, `marker=${info.version}`);
} catch (e) {
check(`snapshot ${v.version} marker parses`, false, String(e.message).slice(0, 120));
}
}
/* 规范原文与 agent 报告是仓库内部资料,不该进快照(同部署包排除规则) */
for (const forbidden of ['.design_library/kole-ui/specs', '.design_library/kole-ui/agent-reports']) {
check(`snapshot ${v.version} excludes ${forbidden}`, !existsSync(join(base, forbidden)));
}
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(base) / 1024 / 1024;
check(`snapshot ${v.version} is non-trivial`, mb > 1, `${mb.toFixed(1)} MB`);
}
}
for (const c of checks) console.log(`[versions] ${c.ok ? 'OK' : 'FAIL'} ${c.name}${c.detail ? ` — ${c.detail}` : ''}`);
if (failures.length) {
console.error(`\n[versions] FAIL (${failures.length})`);
failures.forEach((f) => console.error('- ' + f));
process.exit(1);
}
console.log(`\n[versions] OK — ${checks.length} checks passed`);