Files
SpineWallpaper/tools/build.ts
T
Shuery 3f11426964 feat: TS 构建管线、Spine 抓取器与 wallpapers/ 唯一真相来源
把项目从「手写 dist/」改成「wallpapers/ 是唯一真相来源,dist/ 由 pnpm build 生成」,
并补上配套的类型、门禁与抓取器。一次提交落地整条管线,因为拆开会留下不能构建的中间态。

- src/:运行时与模拟器源码(TS,strict),编译到 build/ 再拷进各分发
- tools/:build / dev / check-{syntax,paths,dist},以及抓取器与回归门禁 tools/checks/
  (.scratch/ 下那批一次性脚本移入 tools/checks/ 并入库为长期门禁)
- wallpapers/:七档壁纸的源数据 + README.md(id/音频/预设的完整规范)
- docs/adr/0005-0008:构建管线与分发拓扑、模拟器契约、自包含 sim、每骨架资源布局
- .gitignore:排除 .scratch/ 的参考资料副本(上游 spine 整仓克隆 ~1.2 GB、
  抓取侦查数据 ~680 MB)与调试转储;这些是本地调查材料,补偿会让仓库无法克隆
- 归一化 .gitignore/CONTEXT.md 行尾(工作区 CRLF、索引 LF 造成的整文件假 diff)

同时修掉三档卡住构建的未完工壁纸:
- kv45 的 meta.json 里 id 还是抓取期场景名 scene_main,经 downloader promote 正名为 kv45
- shajin / zhigengniao_juheye 的 meta.json 误用了骨架描述文件(name/spine/animations/pages)、
  且都缺 preset.template.json;现按规范重建:骨架沉到 spines/<名>/(spine-ts 按 atlas 所在
  目录解析贴图页)、补上元数据与单骨架预设,并清掉 zhigengniao 骨架里指向作者机的绝对路径
- 顺带 promote 已在 sources.yml 里的 kv46(月升之前,与兽共舞)

pnpm check 五道门全绿:10 个分发 / 7 档壁纸 / 141 处引用自包含。
2026-10-02 01:27:02 +08:00

535 lines
25 KiB
TypeScript
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.
// pnpm build —— 把所有分发目录编译成"能直接上传 Wallpaper Engine"的自包含作品。
//
// pnpm build 编全部(所有单档 + 所有游戏合集 + 全部合集)
// pnpm build --single xilian [--single kv37] 编指定单档
// pnpm build --collect hsr 编某游戏的合集
// pnpm build --collect all 编全部合集
// pnpm build --single xilian --collect all 组合:只编这两档
//
// --with-sim 顺带把可选的 WE 模拟器放进各分发的 scripts/(服务端调试用)
// --sim 额外产出**双击就能看**的自包含包 <分发根>/sim/index.html(见 lib/bundle.ts)
// --no-embed-audio --sim 时音频不内联(包小很多,但 file:// 下音频播不了)
// --strict 把 warning 升级为 fail
// --no-clean 保留本次未构建的旧分发目录(默认会删掉,保证产物不残留)
// --dry-run 只打印计划,不落盘
//
// 产物拓扑(dist/releases/<dir>/)见 docs/adr/0005;自包含包见 docs/adr/0007。dist/ 完全可再生产,故不入库。
import { readFile, rm } from "node:fs/promises";
import { join } from "node:path";
import {
abs,
copyFileTo,
dirBytes,
exists,
listDirs,
log,
mb,
readJson,
resetDir,
writeJson,
writeText,
} from "./lib/fs.ts";
import { readVault, sharedAudioOf, VaultError, type Vault } from "./lib/vault.ts";
import {
copyCollectionAudio,
copyWallpaperAssets,
generatePresetIndex,
generatePresetModule,
generateProjectJson,
releasePathOf,
type GenerateContext,
} from "./lib/generate.ts";
import type { BuildOptions, DistMap, ProjectTemplate, Release } from "./lib/types.ts";
import { SIMULATOR_DRIVER, SIMULATOR_URL_STATIC } from "./lib/drivers.ts";
import { checkDist } from "./check-dist.ts";
const RELEASES_DIR = "dist/releases";
const HELP = `用法:
pnpm build 编全部(所有单档 + 所有游戏合集 + 全部合集)
pnpm build <壁纸id> […] 编指定壁纸(可多个)
pnpm build single [壁纸id …] 只编单档(不给 id = 全部单档)
pnpm build collect [游戏id …|all] 只编合集(不给参数 = 全部合集)
pnpm build sim [single|collect|<壁纸id> …] 编全部 + 每档产出自包含包(双击即看,不需要 pnpm dev)
--single <壁纸id> 等价于裸 id --collect <游戏id|all> 编某合集
--with-sim 让分发自带 WE 模拟器面板(静态托管预览用,见下) --sim 产出双击可看的自包含包
--no-embed-audio --sim 时音频不内联
--strict 警告即失败 --no-clean 保留旧分发目录 --dry-run 只打印计划
三种产物,别搞混:
pnpm build 干净产物 → 上传 Wallpaper Engine(不含模拟器,ADR 0006 第 1 条)
pnpm build --with-sim 自带面板 → 丢给任意静态托管(GitHub Pages 等),不需要调试服
pnpm build sim 自包含单文件 → 本地双击打开,不需要任何服务器`;
interface BuildCliOptions {
options: BuildOptions;
withSimulator: boolean;
standalone: boolean;
embedAudio: boolean;
}
/**
* 子命令解析。
*
* 三种子命令对应三种**意图**,而不是三个 flag:
* `single` —— 我在调一档壁纸,只要它;
* `collect` —— 我在调一个合集的组装(预设切换、共享音频);
* `sim` —— 我要能双击打开的成品,每档都要一份。
*
* `sim` 后面还能再跟 `single`/`collect` 收窄范围(`pnpm build sim single kv37`)。
* 不带子命令时保持原来的 flag 风格(`--single` / `--collect`),向后兼容。
*/
function parseArgs(argv: string[]): BuildCliOptions {
const options: BuildOptions = { singles: [], collect: null, strict: false, clean: true, dryRun: false };
let withSimulator = false;
let standalone = false;
let embedAudio = true;
// 先摘子命令。`sim` 后面可以再跟 `single`/`collect`/壁纸 id,所以用循环而不是单次判断。
let start = 0;
while (start < argv.length && !(argv[start] ?? "").startsWith("-")) {
const word = argv[start] ?? "";
if (word === "sim") {
standalone = true;
start += 1;
} else if (word === "single") {
// `pnpm build single kv37 xilian`:后面所有非 flag 的词都是壁纸 id
start += 1;
while (start < argv.length && !(argv[start] ?? "").startsWith("-")) {
options.singles.push(argv[start] ?? "");
start += 1;
}
// 不给 id 就是"全部单档",由 planReleases 的显式标记决定
if (options.singles.length === 0) options.onlySingles = true;
} else if (word === "collect") {
start += 1;
const games: string[] = [];
while (start < argv.length && !(argv[start] ?? "").startsWith("-")) {
games.push(argv[start] ?? "");
start += 1;
}
// 无参 = 全部合集(含每个游戏的合集与「全部壁纸合集」)。
// 这里**不能**用 `collect: "all"` 表示"全部合集"——那个值在 planReleases 里只加
// 「全部壁纸合集」一项,会把 collection-hsr 漏掉。用一个显式标记走"逐游戏 + all"的路径。
if (games.length === 0 || (games.length === 1 && games[0] === "all")) options.allCollections = true;
else options.collect = games;
} else {
/**
* 其余裸词一律当壁纸 id(`pnpm build kv37`、`pnpm build sim kv37`)。
*
* 子命令与壁纸 id 共用同一段位置,靠**词表**区分:`single`/`collect`/`sim` 是保留字,
* 别的裸词都是 id。代价是"想编一个恰好叫 single 的壁纸"做不到——接受,
* 因为 id 由我们命名且都是英文小写,真撞名时改 id 比改语法便宜。
* id 不存在时由 vault 报错,那里能顺带列出所有可用 id,比"未知子命令"有用得多。
*/
options.singles.push(word);
start += 1;
}
}
for (let i = start; i < argv.length; i += 1) {
const arg = argv[i] ?? "";
if (arg === "--single") {
const value = argv[++i];
if (!value || value.startsWith("--")) throw new VaultError("--single 需要跟一个壁纸 id");
options.singles.push(value);
} else if (arg === "--collect") {
const value = argv[++i];
if (!value || value.startsWith("--")) throw new VaultError('--collect 需要跟一个游戏 id 或 "all"');
if (value === "all") options.collect = "all";
else options.collect = [...(Array.isArray(options.collect) ? options.collect : []), value];
} else if (arg === "--with-sim") withSimulator = true;
else if (arg === "--sim") standalone = true;
else if (arg === "--no-embed-audio") embedAudio = false;
else if (arg === "--strict") options.strict = true;
else if (arg === "--no-clean") options.clean = false;
else if (arg === "--dry-run") options.dryRun = true;
else if (arg === "--help" || arg === "-h") {
log(HELP);
process.exit(0);
} else if (arg.startsWith("--")) throw new VaultError(`未知参数:${arg}\n\n${HELP}`);
else if (["sim", "single", "collect"].includes(arg)) {
// 子命令写在 --flag 后面时会被当成壁纸 id。报错时直接点破顺序,
// 否则那句"壁纸要写成 --single sim"会把人引到完全错误的方向。
throw new VaultError(
`子命令 ${arg} 必须写在所有 --flag **之前**:\n` +
` pnpm build ${arg} --with-sim ✓\n` +
` pnpm build --with-sim ${arg} ✗(这里的 ${arg} 会被当成壁纸 id)\n\n${HELP}`,
);
} else throw new VaultError(`未知参数:${arg}(壁纸要写成 --single ${arg})\n\n${HELP}`);
}
return { options, withSimulator, standalone, embedAudio };
}
function normalizeGameId(value: string, vault: Vault): string {
const wanted = value.trim().replace(/[:]/g, ":");
const exact = vault.games.find((g) => g.id === wanted);
if (exact) return exact.id;
const byName = vault.games.find((g) => g.meta.name.replace(/[:]/g, ":") === wanted);
if (byName) return byName.id;
throw new VaultError(`找不到游戏 "${value}"。已有:${vault.games.map((g) => `${g.meta.id}(${g.meta.name})`).join("、")}`);
}
/** 按子命令 / --single / --collect 求出本次要编的分发清单。无参 = 全编。 */
function planReleases(vault: Vault, options: BuildOptions): Release[] {
const releases: Release[] = [];
// `single`(无 id)只编单档;`collect`(无参)只编合集;无参才是全编。
// 三者都不能靠"singles 为空 + collect 为 null"来区分,所以各自有显式标记。
const onlySingles = options.onlySingles === true;
const onlyCollections = options.allCollections === true;
const all = !onlySingles && !onlyCollections && options.singles.length === 0 && options.collect === null;
const addSingle = (wallpaperId: string): void => {
const wallpaper = vault.wallpapers.find((w) => w.id === wallpaperId);
if (!wallpaper) {
throw new VaultError(
`找不到壁纸 "${wallpaperId}"。已有:${vault.wallpapers.map((w) => `${w.id}(${w.meta.name})`).join("、")}`,
);
}
releases.push({
type: "single",
id: wallpaper.id,
// 带上游戏:`wallpaper-kv37` 这种名字在分不清来源的场合(多个游戏各有一档壁纸时)
// 只能靠 id 全局唯一来兜底,而目录名本来就该自解释。与 collection-<游戏id> 同一套读法。
dir: `single-${wallpaper.gameId}-${wallpaper.id}`,
wallpapers: [wallpaper],
displayName: `${wallpaper.game?.name ?? wallpaper.gameId} - ${wallpaper.meta.name}`,
meta: {
title: wallpaper.meta.title,
description: wallpaper.meta.description,
...(wallpaper.meta.preview ? { preview: wallpaper.meta.preview } : {}),
...(wallpaper.meta.workshopid ? { workshopid: wallpaper.meta.workshopid } : {}),
...(wallpaper.meta.workshopurl ? { workshopurl: wallpaper.meta.workshopurl } : {}),
},
metaRel: wallpaper.srcRel,
sharedAudio: [],
});
};
const addGame = (gameId: string): void => {
const game = vault.games.find((g) => g.id === gameId);
if (!game) throw new VaultError(`内部错误:找不到游戏 ${gameId}`);
releases.push({
type: "collection",
scope: "game",
id: game.id,
dir: `collection-${game.id}`,
wallpapers: [...game.wallpapers],
displayName: `${game.meta.name}合集`,
meta: {
// 文案取自**游戏级** meta(见 wallpapers/README.md 的三层分工),不回落到全局 title:
// 全局那层是给「全部合集」写的,回落会让 collection-ys 顶着《崩坏:星穹铁道》昔涟。
title: game.meta.title,
description:
game.meta.description ??
`[b]${game.meta.name} 全部壁纸[/b]\r\n[list]\r\n [*]共 ${game.wallpapers.length} 档:${game.wallpapers
.map((w) => w.meta.name)
.join("、")}。\r\n [*]bgm 与音量可在壁纸设置中调整。\r\n[/list]`,
},
metaRel: game.srcRel,
sharedAudio: sharedAudioOf(game),
});
};
const addAll = (): void => {
releases.push({
type: "collection",
scope: "all",
id: "all",
dir: "collection-all",
wallpapers: [...vault.wallpapers],
displayName: vault.global.name,
meta: {
title: vault.global.title,
description: vault.global.description,
...(vault.global.preview ? { preview: vault.global.preview } : {}),
...(vault.global.workshopid ? { workshopid: vault.global.workshopid } : {}),
...(vault.global.workshopurl ? { workshopurl: vault.global.workshopurl } : {}),
},
metaRel: "wallpapers",
sharedAudio: vault.games.flatMap((game) => sharedAudioOf(game)),
});
};
if (all) {
for (const wallpaper of vault.wallpapers) addSingle(wallpaper.id);
for (const game of vault.games) addGame(game.id);
addAll();
return releases;
}
if (onlySingles) {
for (const wallpaper of vault.wallpapers) addSingle(wallpaper.id);
return releases;
}
// `pnpm build collect`(无参)= 每个游戏的合集 + 「全部壁纸合集」。
// 注意 `--collect all` 是另一件事:它只编「全部壁纸合集」一项,保持原语义不变。
if (options.allCollections === true) {
for (const game of vault.games) addGame(game.id);
addAll();
return releases;
}
for (const id of options.singles) addSingle(id);
if (options.collect === "all") addAll();
else if (Array.isArray(options.collect)) for (const raw of options.collect) addGame(normalizeGameId(raw, vault));
return releases;
}
/**
* 把 `index.html` 模板渲染成产物。
*
* 默认**原样输出**:发布产物的 index.html 里不能有任何模拟器痕迹(ADR 0006 第 1 条)——
* 旧实现会在真实 WE 里自我激活并覆盖官方 API,事故级。
*
* `--with-sim` 时才注入模拟器驱动,且用**相对路径** `./scripts/wallpaper-engine.js`。
* 这样整个分发目录是自足的:丢到任何静态托管(GitHub Pages 的 `/<repo>/` 子路径、
* 任意 CDN、甚至 file://)都能弹出设置面板,不需要调试服在场。
*
* 注入点必须在 `<script type="module" src="./scripts/index.js">` **之前**:
* 驱动是经典脚本,要赶在壁纸模块注册 `wallpaperPropertyListener` 之前把钩子备好
* (见 tools/lib/drivers.ts 的时序说明)。
*/
function renderIndexHtml(template: string, release: Release, ctx: GenerateContext): string {
if (!ctx.withSimulator) return template;
const driver = SIMULATOR_DRIVER({
search: "",
dir: release.dir,
title: release.displayName,
version: ctx.version,
defaultPresetId: ctx.defaultPresetId,
preview: release.meta.preview,
simulatorUrl: SIMULATOR_URL_STATIC,
});
const marker = /[ \t]*<script\b[^>]*src=["'][^"']*scripts\/index\.js["'][^>]*><\/script>/i;
if (!marker.test(template)) {
throw new VaultError("src/templates/index.html 里找不到 scripts/index.js 的 <script> 标签,模拟器无法注入");
}
return template.replace(marker, (tag) => `${driver}\n ${tag.trim()}`);
}
async function buildRelease(
release: Release,
ctx: GenerateContext,
options: BuildOptions,
warnings: string[],
): Promise<{ bytes: number; simBytes: number }> {
const outDir = join(ctx.outDir, release.dir);
if (options.dryRun) {
log(` [dry-run] ${release.dir.padEnd(24)} ← ${release.wallpapers.map((w) => w.id).join(", ")}`);
return { bytes: 0, simBytes: 0 };
}
await resetDir(outDir);
// 1) 模板与静态资源
await writeText(join(outDir, "index.html"), renderIndexHtml(await readFile(abs("src/templates/index.html"), "utf8"), release, ctx));
await writeJson(join(outDir, "project.json"), generateProjectJson(release, ctx));
for (const name of ["index.css", "spine-player.css"]) {
await copyFileTo(abs(`src/styles/${name}`), join(outDir, "styles", name));
}
await copyFileTo(abs("src/vendor/spine-player.js"), join(outDir, "scripts", "spine-player.js"));
// 分发级预览图:`preview` 字段指向的是**分发根**下的文件名,所以图源必须在分发自己的元数据目录里
// 找到同名文件。当前只有「所有壁纸合集」有它(wallpapers/preview.gif);其余分发没有,字段就省略。
if (release.meta.preview) {
const name = release.meta.preview.replace(/\\/g, "/").replace(/^.*\//, "");
const source = join(abs(release.metaRel), name);
if (await exists(source)) await copyFileTo(source, join(outDir, name));
else warnings.push(`${release.dir}: project.json 声明了 preview=${release.meta.preview},但 ${release.metaRel}/${name} 不存在`);
}
// 2) 运行时脚本(tsc 已把 src/runtime/*.ts 编到 build/scripts/)
for (const name of [
"index.js",
"preset-controller.js",
"background-controller.js",
"spine-controller.js",
"scene-controller.js",
"audio-controller.js",
"viewport-fitter.js",
]) {
const from = abs(`build/scripts/${name}`);
if (!(await exists(from))) {
throw new VaultError(`缺少 ${from}。请先跑 \`pnpm typecheck\` 之外的编译:tsc -p tsconfig.runtime.json(pnpm build 的 npm script 已包含)`);
}
await copyFileTo(from, join(outDir, "scripts", name));
}
if (ctx.withSimulator) {
const simulator = abs("build/scripts/wallpaper-engine.js");
if (await exists(simulator)) await copyFileTo(simulator, join(outDir, "scripts", "wallpaper-engine.js"));
else warnings.push("--with-sim 已指定,但 build/scripts/wallpaper-engine.js 不存在(先跑 pnpm build:sim)");
}
// 3) 预设索引(每分发现算,见 generatePresetIndex 注释)
await writeText(join(outDir, "scripts", "presets.js"), generatePresetIndex(release, ctx.defaultPresetId));
// 4) 音频落点必须在生成 preset.js **之前**算出来:路径是按实际落点现算的,
// 先写 preset.js 再决定音频放哪儿,会出现"语法正确、指向空气"的产物。
// 落点算法本身只有一份(generate.ts 的 planAudio),这里只负责把文件搬过去。
const isCollection = release.type !== "single";
if (isCollection) {
const placed = await copyCollectionAudio(release, outDir);
if (placed.names.length === 0) warnings.push(`${release.dir}: 合集根 audios/ 为空(各壁纸与游戏都没有声明共享音频)`);
}
// 5) 每档壁纸的 preset.js + 资源
for (const wallpaper of release.wallpapers) {
const sub = releasePathOf(release, wallpaper);
const destDir = sub ? join(outDir, sub) : outDir;
await writeText(join(destDir, "preset.js"), generatePresetModule(release, wallpaper, release.sharedAudio));
await copyWallpaperAssets(wallpaper, destDir, { skipAudio: isCollection });
}
// 6) 可选的**自包含包**:与发布产物并列放在 sim/ 下,互不干扰。
// 它必须在资源全部落地之后才能生成——内联的是刚铺好的那些文件。
let simBytes = 0;
if (options.standalone) {
const { buildSimPage } = await import("./lib/bundle.ts");
const sim = await buildSimPage(release, ctx.defaultPresetId, { embedAudio: options.embedAudio ?? true });
const simDir = join(outDir, "sim");
await writeText(join(simDir, "index.html"), sim.html);
simBytes = await dirBytes(simDir);
log(
` └ sim/index.html ${mb(simBytes)} 内联 ${sim.inlined} 项资源` +
(sim.skippedAudio.length > 0 ? `(${sim.skippedAudio.length} 个音频未内联,file:// 下不可播)` : ""),
);
if (sim.skippedAudio.length > 0) {
warnings.push(`${release.dir}: --no-embed-audio,${sim.skippedAudio.length} 个音频未内联,双击打开时选不到它们`);
}
}
return { bytes: await dirBytesExcludingSim(outDir), simBytes };
}
/**
* 分发体积:**不含** `sim/`。
*
* 自包含包里的图片/音频是 base64 内联,体积与发布产物不在一个量级;把它算进"上传体积"
* 会让人误判发布产物的实际大小。两笔分开报。
*/
async function dirBytesExcludingSim(root: string): Promise<number> {
const { readdir, stat } = await import("node:fs/promises");
let total = 0;
for (const entry of await readdir(root, { withFileTypes: true })) {
if (entry.name === "sim") continue;
const full = join(root, entry.name);
if (entry.isDirectory()) total += await dirBytesExcludingSim(full);
else total += (await stat(full)).size;
}
return total;
}
async function main(): Promise<void> {
const started = Date.now();
const cli = parseArgs(process.argv.slice(2));
const { options, withSimulator, standalone } = cli;
options.standalone = standalone;
options.embedAudio = cli.embedAudio;
const version = (await readFile(abs("VERSION"), "utf8")).trim();
if (!/^\d+$/.test(version)) throw new VaultError(`VERSION 必须是单个整数,当前是 "${version}"`);
const vault = await readVault();
const template = await readJson<ProjectTemplate>(abs("src/project.template.json"));
const releases = planReleases(vault, options);
log(`SpineWallpaper build version=${version} releases=${releases.length}${withSimulator ? " (with simulator)" : ""}${standalone ? " (standalone sim)" : ""}`);
log(` 壁纸:${vault.wallpapers.map((w) => `${w.id}(${w.meta.name})`).join("、")}`);
log(` 游戏:${vault.games.map((g) => `${g.id}(${g.meta.name})`).join("、")}`);
log("");
const warnings: string[] = [];
const wantedDefault = vault.global.defaultPresetId;
if (wantedDefault !== undefined && !vault.wallpapers.some((w) => w.id === wantedDefault)) {
throw new VaultError(
`wallpapers/meta.json 的 defaultPresetId "${wantedDefault}" 不是任何壁纸的 id(已有:${vault.wallpapers
.map((w) => w.id)
.join("、")})`,
);
}
/** 某档分发的默认预设:单档恒等于它自己;合集用全局声明,若该合集里没有就退回第一档。 */
const defaultPresetIdFor = (release: Release): string => {
if (release.type === "single") return release.wallpapers[0]?.id ?? "";
if (wantedDefault && release.wallpapers.some((w) => w.id === wantedDefault)) return wantedDefault;
return release.wallpapers[0]?.id ?? "";
};
const ctx = { template, version, outDir: abs(RELEASES_DIR), withSimulator } satisfies Omit<
GenerateContext,
"defaultPresetId"
>;
const mapEntries: DistMap["releases"] = [];
for (const release of releases) {
const defaultPresetId = defaultPresetIdFor(release);
// 只有一档的合集拿不到 preset 下拉(generateProjectJson 按档数决定),产物行为与单档分发相同。
// 发不发这一档是发布级的判断,构建不替人决定,但必须让人看见——"合集"名不副实是真实的踩坑点。
if (release.type === "collection" && release.wallpapers.length < 2) {
warnings.push(
`${release.dir}: 合集只收 ${release.wallpapers.length} 档壁纸,产物里不会有 preset 下拉(行为等同单档);要么补壁纸,要么不发这一档`,
);
}
const sizes = await buildRelease(release, { ...ctx, defaultPresetId }, options, warnings);
mapEntries.push({
dir: release.dir,
type: release.type,
// 合集范围(单档没有):只有它才区分「游戏合集」与「全部合集」。
...(release.scope ? { scope: release.scope } : {}),
id: release.id,
displayName: release.displayName,
defaultPresetId,
wallpapers: release.wallpapers.map((wallpaper) => ({
id: wallpaper.id,
gameId: wallpaper.gameId,
dir: releasePathOf(release, wallpaper),
name: wallpaper.meta.name,
})),
bytes: sizes.bytes,
...(sizes.simBytes > 0 ? { simBytes: sizes.simBytes } : {}),
});
log(` ✓ ${release.dir.padEnd(24)} ${release.wallpapers.length} 档壁纸 ${mb(sizes.bytes)}`);
}
if (!options.dryRun) {
if (options.clean) {
const built = new Set(mapEntries.map((e) => e.dir));
for (const dir of await listDirs(abs(RELEASES_DIR))) {
if (!built.has(dir)) {
await rm(join(abs(RELEASES_DIR), dir), { recursive: true, force: true });
log(` − 删除孤立的旧分发目录 ${dir}`);
}
}
} else {
for (const dir of await listDirs(abs(RELEASES_DIR))) {
if (!mapEntries.some((e) => e.dir === dir)) {
warnings.push(`dist/releases/${dir} 不是本次构建的产物(--no-clean),dist-map.json 里没有它`);
}
}
}
const map: DistMap = { version, builtAt: new Date().toISOString(), releases: mapEntries };
await writeJson(abs("dist/dist-map.json"), map);
await checkDist({ strict: options.strict });
}
log("");
for (const warning of warnings) log(` ⚠ ${warning}`);
if (options.strict && warnings.length > 0) throw new VaultError(`--strict:出现 ${warnings.length} 条警告,视为失败`);
log(`完成,用时 ${((Date.now() - started) / 1000).toFixed(1)}s`);
}
try {
await main();
} catch (error) {
if (error instanceof VaultError) {
console.error(`\n构建失败:${error.message}\n`);
process.exit(1);
}
throw error;
}