Files
ClassHelper/scripts/version.mjs
T
LoveChengke 0ea6bd887f feat(website,docs,build): 官网加一个下载页(挑版本装机),文档站的照片换成与 README 同一份
下载页(`website/download.html` + `assets/download.js`)

- 官网首页的「立即下载」、顶栏与页脚的「取安装包」全部指向新页 `/download.html`。
  那一页是「先挑一个版本,再挑要装的那一份」:一个版本分段控件 + 四张卡
  (班级端 / 服务端 Windows / 服务端 Linux / ClassIsland 插件),每张卡列出这一版里
  真实存在的文件、体积与直链;
- 版本与文件清单**不在站点里存副本**,每次打开都由 `assets/download.js` 当场读 GitHub
  Releases —— 文件名、体积、链接照抄发布页,不靠命名约定去猜;
- 读不到时(离线,或匿名接口 60 次/小时按出口 IP 被限流)退回页面上那份
  `<meta name="ch-version">`,按发布命名约定拼直链,并在页面上**明说这是拼出来的**;
- 老版本缺哪个包就显示「这一版没有发布…」(`v0.1.0-fix` 只有插件包,实测正确);
  Linux 那一行安装命令跟着选中版本钉上 `--version`(tag 不是 `x.y.z` 时不带);
- 动态建出来的卡片与文件行派发 `ch:content`,由 `main.js` 补挂按压反馈
  —— 这些元素是拉到清单之后才出现的,启动时还看不到它们。

文档站的照片(`docs/images/`)

- 页面里的图一律从 `docs/screenshots/**` 换成 `docs/images/**`:后者就是 README 与官网
  吃的那一份(字节相同),前者是 9-26 的冒烟留档,比 README 落后一个版本;
- 展示图与回归留档这两套图的分工写进 `docs/dev/testing.md`「截图留档」,含重新采集命令
  (冒烟会把「岛外留白对照图」落在同一个目录,所以要先落临时目录再挑);
- 顺带修 README 手机截图那张标错的图注(`mobile-dashboard.jpg` 是仪表盘,不是抽屉菜单);
- `docs/reference.md` 验收表里那张 ClassIsland「真机实测截图」不再嵌图:它是连着桌面、
  任务栏和另一个窗口整屏截下来的,当文档配图不能用。断言与用例名都保留,另外注明了抓图位置。

版本落点

- `scripts/version.mjs` 增加 `website/download.html` 的兜底版本号,落点 13 → 14 处
  (`AGENTS.md` §4.4 与 `docs/dev/release.md` 同步);
- README「开始使用」表里那三个文件名改成**发布页上真实的名字**
  (`-x64-client-setup` / `-x64-client-portable` / `-x64-server-setup`)—— 原来写的是构建
  产物的名字;「上传前把产物改成 ASCII 名」这一步也一并写进发布流程,
  它是官网下载页离线兜底与 README 表格的共同依据。
2026-10-06 18:37:42 +08:00

228 lines
9.8 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
/**
* 版本号的读写与一致性校验 —— `pnpm version:check` / `pnpm version:bump <级别>`。
*
* ## 为什么要这个脚本
*
* 这个仓库里**同一个版本号写在 14 处(12 个文件)**:5 份 package.json、运行时常量、两个 compose 文件、
* 插件清单、插件 C# 源码里的一个 const,以及官网页面上三处手写的版本号、下载页那一处兜底值。
* 手工改必然漏一处,
* 而漏掉的那处**不会报错**,只会在某个不常走的分支上表现为"版本号对不上":
*
* - `deploy/docker-compose*.yml` 镜像 tag 落后 → 服务器上跑的还是旧镜像;
* - `deploy/package.runtime.json` 容器 / 安装包里那份依赖清单的版本(人看,但会误导排查);
* - `manifest.yml` + `BridgeService.cs` ClassIsland 上显示的插件版本
* (清单里的 version 与实际上报的 PluginVersion 不一致时,
* Web 端「ClassIsland 联动」页显示的版本会对不上插件市场);
* - `website/index.html` 的那三处 官网是纯静态站、无构建步骤,没有哪段代码会去读
* `package.json`,漏改的后果是**首页上一直挂着旧版本号** —— 那是别人看到的第一眼;
* - `website/download.html` 的 `<meta name="ch-version">` 下载页拿它当"读不到版本清单"时的兜底,
* 漏改的后果是**离线访客点到的下载链接指向一个不存在的版本**。
*
* 因此:**改版本号的唯一入口是这个脚本**。它按表逐个改写,任何一处没匹配到(或匹配到多处)
* 就直接失败退出,不做"尽力而为"的部分改写。
*
* ## 用法
*
* node scripts/version.mjs # 检查(默认):列出各处版本,不一致则 exit 1
* node scripts/version.mjs --bump patch # 1.1.2 → 1.1.3
* node scripts/version.mjs --bump minor # 1.1.2 → 1.2.0
* node scripts/version.mjs --bump major # 1.1.2 → 2.0.0
* node scripts/version.mjs --set 1.2.0 # 显式指定(插件同时设为 1.2.0.0)
* node scripts/version.mjs --bump minor --dry # 只看会改哪些文件,不落盘
*
* 版本口径与完整发布流程见 docs/dev/release.md。
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
/**
* `pattern` 一律写成**三个捕获组**:`(前缀)(版本号)(后缀)`。
* 前缀与后缀原样保留,中间的版本号被替换 —— 这样既能精确替换,也能取出来比对。
*/
const PRODUCT_TARGETS = [
{ file: 'package.json', pattern: /("version"\s*:\s*")(\d+\.\d+\.\d+)(")/ },
{ file: 'packages/shared/package.json', pattern: /("version"\s*:\s*")(\d+\.\d+\.\d+)(")/ },
{ file: 'packages/server/package.json', pattern: /("version"\s*:\s*")(\d+\.\d+\.\d+)(")/ },
{ file: 'packages/web-admin/package.json', pattern: /("version"\s*:\s*")(\d+\.\d+\.\d+)(")/ },
{ file: 'packages/desktop-client/package.json', pattern: /("version"\s*:\s*")(\d+\.\d+\.\d+)(")/ },
{ file: 'deploy/package.runtime.json', pattern: /("version"\s*:\s*")(\d+\.\d+\.\d+)(")/ },
{
file: 'deploy/docker-compose.yml',
pattern: /(classhelper-server:\$\{APP_VERSION:-)(\d+\.\d+\.\d+)(\})/,
},
{
file: 'deploy/docker-compose.sqlite.yml',
pattern: /(classhelper-server:\$\{APP_VERSION:-)(\d+\.\d+\.\d+)(\})/,
},
];
/**
* 插件版本(`x.y.z.0`)。第 4 段留给"只改插件、不动产品版本"的补丁发布;
* `--bump` / `--set` 一律把它归零,要单独发插件补丁时手工改这两处即可。
*/
const PLUGIN_TARGETS = [
{
file: 'packages/classisland-plugin/manifest.yml',
pattern: /^(version:[ \t]*)(\d+\.\d+\.\d+\.\d+)([ \t]*)$/m,
},
{
file: 'packages/classisland-plugin/src/Services/BridgeService.cs',
pattern: /(private const string PluginVersion = ")(\d+\.\d+\.\d+\.\d+)(";)/,
},
];
/**
* 官网首页与下载页上那几处手写的版本号。官网是纯静态站,没有构建步骤、也没有哪段代码会去读
* `package.json`,所以这几处只能靠脚本一起改 —— 漏了的话官网会一直显示旧版本号,
* 而它是别人看到的第一眼。
*/
const SITE_TARGETS = [
{
file: 'website/index.html',
label: '(顶栏徽标)',
pattern: /(<span class="brand-ver">)(\d+\.\d+\.\d+)(<\/span>)/,
},
{ file: 'website/index.html', label: '(首屏芯片)', pattern: /(<li>v)(\d+\.\d+\.\d+)(<\/li>)/ },
{
file: 'website/index.html',
label: '(页脚)',
pattern: /(<p class="foot-ver">版本 )(\d+\.\d+\.\d+)( ·)/,
},
{
file: 'website/download.html',
label: '(版本清单的兜底值)',
pattern: /(<meta name="ch-version" content=")(\d+\.\d+\.\d+)(" \/>)/,
},
];
const ALL_TARGETS = [...PRODUCT_TARGETS, ...PLUGIN_TARGETS, ...SITE_TARGETS];
const PRODUCT_RE = /^\d+\.\d+\.\d+$/;
const PLUGIN_RE = /^\d+\.\d+\.\d+\.\d+$/;
/** 输出用的名字:同一个文件里有多个落点时带上括号里的说明,否则三行看起来一模一样 */
function name(target) {
return target.label ? `${target.file} ${target.label}` : target.file;
}
function readTarget(target) {
const source = fs.readFileSync(path.join(root, target.file), 'utf8');
const flags = target.pattern.flags.includes('g') ? target.pattern.flags : `${target.pattern.flags}g`;
const hits = [...source.matchAll(new RegExp(target.pattern.source, flags))];
return { source, hits, global: new RegExp(target.pattern.source, flags) };
}
/** 取出某个文件当前声明的版本号;没匹配到或匹配到多处都返回 null。 */
function readVersion(target) {
const { hits } = readTarget(target);
return hits.length === 1 ? hits[0][2] : null;
}
/** 改写一处。缺失或重复命中都算失败 —— 静默漏改比直接报错危险得多。 */
function writeVersion(target, next, { dry }) {
const { source, hits, global } = readTarget(target);
if (hits.length !== 1) {
throw new Error(
`${target.file} 里匹配到 ${hits.length} 处版本号(应为 1 处)。` +
' 模板被改过?请同步 scripts/version.mjs 里的 pattern 表。',
);
}
if (!dry) {
const replaced = source.replace(global, (_m, prefix, _old, suffix) => `${prefix}${next}${suffix}`);
fs.writeFileSync(path.join(root, target.file), replaced, 'utf8');
}
return `${name(target)} ${hits[0][2]} → ${next}`;
}
function bumpProduct(current, level) {
const [major, minor, patch] = current.split('.').map(Number);
if (level === 'major') return `${major + 1}.0.0`;
if (level === 'minor') return `${major}.${minor + 1}.0`;
if (level === 'patch') return `${major}.${minor}.${patch + 1}`;
throw new Error(`未知的级别 ${level}(可选:major / minor / patch,或用 --set x.y.z)`);
}
/** 检查模式:逐处列出,并给出不一致清单。 */
function check(product) {
console.log(`产品版本(单一来源:根 package.json):${product}\n`);
const width = Math.max(...ALL_TARGETS.map((target) => target.file.length));
const problems = [];
for (const target of ALL_TARGETS) {
const version = readVersion(target);
if (!version) {
console.log(`?? ${name(target).padEnd(width)} (未匹配到版本号)`);
problems.push(`${target.file}:匹配不到版本号`);
continue;
}
// 插件版本是四段,只校验前三段;第 4 段允许不为 0(插件专属补丁号)
const expected = PLUGIN_RE.test(version) ? `${product}.0` : product;
const ok = PLUGIN_RE.test(version)
? version.split('.').slice(0, 3).join('.') === product
: version === expected;
console.log(`${ok ? ' ' : '!!'} ${name(target).padEnd(width)} ${version}`);
if (!ok) problems.push(`${target.file}:${version} ≠ ${expected}`);
}
if (problems.length) {
console.error('\n版本号不一致:');
for (const problem of problems) console.error(` - ${problem}`);
console.error('\n对齐方式:pnpm version:check 之后用 pnpm version:bump patch 统一改写。');
process.exit(1);
}
console.log('\n全部一致 ✅(插件第 4 段是插件专属补丁号,允许不为 0)');
}
function apply(next, { dry, product }) {
const pluginNext = `${next}.0`;
const lines = [];
for (const target of PRODUCT_TARGETS) lines.push(writeVersion(target, next, { dry }));
for (const target of PLUGIN_TARGETS) lines.push(writeVersion(target, pluginNext, { dry }));
for (const target of SITE_TARGETS) lines.push(writeVersion(target, next, { dry }));
console.log(
`${dry ? '[dry] 将改写' : '已改写'} ${product} → ${next}(插件 ${product}.0 → ${pluginNext}):\n`,
);
for (const line of lines) console.log(` ${line}`);
console.log('\n接着按 docs/dev/release.md 走发布流程;GitHub Release 的 tag(v' + next + ')也要对齐。');
}
function main() {
const args = process.argv.slice(2);
const dry = args.includes('--dry');
const bumpIndex = args.indexOf('--bump');
const setIndex = args.indexOf('--set');
const product = readVersion(PRODUCT_TARGETS[0]); // 根 package.json = 单一来源
if (!product || !PRODUCT_RE.test(product)) {
throw new Error('读不到根 package.json 的版本号(或它不是 x.y.z 形式)');
}
let next = null;
if (bumpIndex >= 0) {
const level = args[bumpIndex + 1];
if (!level) throw new Error('--bump 后面要跟 patch / minor / major');
next = bumpProduct(product, level);
} else if (setIndex >= 0) {
const value = args[setIndex + 1];
if (!value) throw new Error('--set 后面要跟 x.y.z');
if (!PRODUCT_RE.test(value)) throw new Error(`--set 的取值 ${value} 不是 x.y.z 形式`);
next = value;
}
if (!next) check(product);
else apply(next, { dry, product });
}
try {
main();
} catch (error) {
console.error(`[version] ${error.message}`);
process.exit(1);
}