// pnpm check:dist —— 分发目录的自我包含性门禁。 // // A2 的要求是"分发目录应避免跨目录相对链接与绝对链接"(它能被整体拷到别的机器上传)。 // 这里把它变成硬门禁:扫描每个分发目录里的 HTML/CSS/JS,把每一处**静态可解析**的引用 // 解析出来,凡是落到分发目录之外的、或写成根绝对路径(/…)与 file:/// 的,一律 fail。 // // 不做的事:不去猜 `asset()` 这类运行时推导(那是生成器的责任,生成器只吐相对路径)。 import { readFile } from "node:fs/promises"; import { join, resolve } from "node:path"; import { abs, exists, listDirs, log, readJson, walkFiles } from "./lib/fs.ts"; import { VaultError } from "./lib/vault.ts"; import type { DistMap } from "./lib/types.ts"; export interface CheckResult { releases: number; files: number; references: number; problems: string[]; warnings: string[]; } /** 允许出现的非本地 URL 协议与内联数据。 */ const ALLOWED_PROTOCOL = /^(data:|blob:|mailto:|https?:\/\/|steam:|#)/i; interface Reference { /** 引用写下的原始文本。 */ raw: string; /** 引用的文件(相对分发根)。 */ file: string; /** 行号(1 起)。 */ line: number; } /** 从一份文本里抽出所有静态可解析的本地引用。 */ function extractReferences(file: string, text: string): Reference[] { const out: Reference[] = []; const push = (raw: string, line: number): void => { if (!raw) return; const normalized = raw.trim(); if (ALLOWED_PROTOCOL.test(normalized)) return; out.push({ raw: normalized, file, line }); }; text.split(/\r?\n/).forEach((line, index) => { const lineNo = index + 1; const isCss = /\.css$/i.test(file); // HTML/JS 里的字符串形态:src="/x"、href='y'、from "./z"、import("w")、fetch("v") for (const match of line.matchAll(/(?:src|href|poster)\s*=\s*["']([^"']+)["']/g)) push(match[1] ?? "", lineNo); for (const match of line.matchAll(/(?:from|import|fetch)\s*\(?\s*["']([^"']+)["']/g)) push(match[1] ?? "", lineNo); // url(...) 只在 CSS 里是路径;JS 里 `url("${x}")` 是模板字符串,不是静态引用 if (isCss) { for (const match of line.matchAll(/url\(\s*["']?([^"')]+)["']?\s*\)/g)) push(match[1] ?? "", lineNo); for (const match of line.matchAll(/@import\s+["']([^"']+)["']/g)) push(match[1] ?? "", lineNo); } }); return out; } /** 解析一个引用:返回它相对分发根的路径,或一个错误原因。 */ function resolveReference(raw: string, file: string): { rel: string } | { error: string } { if (raw.startsWith("file:///")) return { error: `绝对链接 file:///(分发目录必须可整体搬走)` }; if (raw.startsWith("//")) return { error: `协议相对链接 //(依赖宿主,跨机器行为不定)` }; if (raw.startsWith("/")) return { error: `根绝对路径 /(脱离分发根就没有意义)` }; const clean = raw.split(/[?#]/)[0] ?? ""; const parts = (file.includes("/") ? file.replace(/\/[^/]*$/, "/") : "") + clean; const segments: string[] = []; for (const segment of parts.split("/")) { if (segment === "" || segment === ".") continue; if (segment === "..") { if (segments.length === 0) return { error: `相对路径越出分发目录(${raw})` }; segments.pop(); continue; } segments.push(segment); } return { rel: segments.join("/") }; } /** 从 preset.js 里抽出所有声明的音频落点(相对该 preset.js 所在目录)。 */ function extractAudioSources(text: string): { raw: string; line: number }[] { const out: { raw: string; line: number }[] = []; text.split(/\r?\n/).forEach((line, index) => { for (const match of line.matchAll(/source:\s*asset\(\s*"([^"]*)"\s*\)/g)) { out.push({ raw: match[1] ?? "", line: index + 1 }); } }); return out; } /** * 让 V8 真的解析一个产物模块,只求语法通过、不执行。 * * 为什么非要有这一步:`presets.js` 与 `preset.js` 是**生成器拼出来的裸 JS 文本**, * tsc 完全不看它们。生成器写出非法语法时,前面所有检查(引用扫描、路径存在性)都会通过, * 直到浏览器里爆 SyntaxError 白屏——实测过一次真事故(数组字面量里写了计算属性名)。 * * 用 vm.SourceTextModule 而不是 dynamic import:后者会**执行**模块,而壁纸脚本依赖 DOM。 * 用子进程是因为这个 API 需要 --experimental-vm-modules(V8 的解析器只暴露到这里)。 */ async function checkModuleSyntax(files: { path: string; text: string }[]): Promise { const { spawnSync } = await import("node:child_process"); const script = ` const vm = require("node:vm"); const payload = JSON.parse(require("node:fs").readFileSync(0, "utf8")); const problems = []; for (const item of payload) { try { new vm.SourceTextModule(item.text, { identifier: item.path }); } catch (error) { problems.push(item.path + " " + String(error.message).split("\\n")[0]); } } process.stdout.write(JSON.stringify(problems)); `; const child = spawnSync(process.execPath, ["--experimental-vm-modules", "--no-warnings", "-e", script], { input: JSON.stringify(files), encoding: "utf8", }); if (child.status !== 0) { return [`无法运行语法解析子进程:${(child.stderr || "").trim().split("\n").slice(-3).join(" ")}`]; } try { return JSON.parse(child.stdout) as string[]; } catch { return [`语法解析子进程输出无法解析:${child.stdout.slice(0, 200)}`]; } } export async function checkDist(options: { strict?: boolean; quiet?: boolean } = {}): Promise { const releasesDir = abs("dist/releases"); const mapPath = abs("dist/dist-map.json"); const result: CheckResult = { releases: 0, files: 0, references: 0, problems: [], warnings: [] }; if (!(await exists(mapPath))) { result.problems.push("dist/dist-map.json 不存在(先跑 pnpm build)"); if (!options.quiet) report(result); return result; } const map = await readJson(mapPath); const modules: { path: string; text: string }[] = []; for (const dir of await listDirs(releasesDir)) { const root = join(releasesDir, dir); if (!map.releases.some((r) => r.dir === dir)) { result.problems.push(`dist/releases/${dir} 不在 dist-map.json 里(残留目录)`); continue; } result.releases += 1; const entry = map.releases.find((r) => r.dir === dir); if (entry) { // project.json 的静态字段必须指向目录内真实存在的文件 const project = await readJson<{ preview?: string; file?: string }>(join(root, "project.json")); const fileField = project.file ?? "index.html"; if (!(await exists(join(root, fileField)))) result.problems.push(`${dir}/project.json 的 file 指向不存在的 ${fileField}`); if (project.preview !== undefined && !(await exists(join(root, project.preview)))) { result.problems.push(`${dir}/project.json 的 preview 指向不存在的 ${project.preview}`); } if (entry.defaultPresetId.length === 0) result.problems.push(`${dir}: dist-map 里没有 defaultPresetId`); // 运行时脚本必须齐全(少了任何一个都会在 WE 里静默白屏) for (const name of [ "scripts/index.js", "scripts/presets.js", "scripts/spine-player.js", "scripts/preset-controller.js", "scripts/background-controller.js", "scripts/spine-controller.js", "scripts/audio-controller.js", "scripts/viewport-fitter.js", "styles/index.css", "styles/spine-player.css", "index.html", ]) { if (!(await exists(join(root, name)))) result.problems.push(`${dir}: 缺少 ${name}`); } // 调试服的热更新客户端**绝不能**进产物:它连的是 /__dev/events,那在真实 WE 里、 // 在任何静态托管上都不存在,只会白挨一次连接失败。这类"调试期专有代码混进发布产物" // 靠人工抽查迟早会漏,所以钉在门禁上。 const indexText = await readFile(join(root, "index.html"), "utf8"); for (const marker of ["/__dev/events", "dev-reload-pill"]) { if (indexText.includes(marker)) { result.problems.push(`${dir}/index.html 里有调试服专属的 ${marker}`); } } // 每档壁纸的 preset.js 必须与 dist-map 声明一致 for (const wallpaper of entry.wallpapers) { const presetPath = wallpaper.dir ? `${wallpaper.dir}/preset.js` : "preset.js"; if (!(await exists(join(root, presetPath)))) result.problems.push(`${dir}: 缺少 ${presetPath}`); } const presetModules = (await walkFiles(root)).filter((f) => /(^|\/)preset\.js$/.test(f)); if (presetModules.length !== entry.wallpapers.length) { result.problems.push( `${dir}: preset.js 数量(${presetModules.length}) 与 dist-map 声明的壁纸数(${entry.wallpapers.length}) 不一致`, ); } // 语义校验:preset.js 里声明的每个音频落点都必须真实存在。 // // 静态引用扫描(上面那圈)抓不到这类缺陷:音频走的是 `asset("../audios/x.flac")`, // 参数是字面量,但 `asset` 的基准是 preset.js 自己的目录,只有按 preset.js 的位置解析才 // 知道对不对。合集分发把音频搬到分发根,前缀算错一层就会得到一个"语法正确、路径错误"的 // preset.js —— 它会一路通过编译、通过引用扫描,然后在 WE 里静音。 for (const presetRel of presetModules) { const presetText = await readFile(join(root, presetRel), "utf8"); for (const audio of extractAudioSources(presetText)) { if (audio.raw === "") continue; // 没有默认音源时是空串,跳过 const resolved = resolveReference(audio.raw, presetRel); if ("error" in resolved) { result.problems.push(`${dir}/${presetRel}:${audio.line} 音频 ${resolved.error}`); continue; } if (!(await exists(join(root, resolved.rel)))) { result.problems.push(`${dir}/${presetRel}:${audio.line} 音频引用不存在的 ${audio.raw}`); } } } // 单档分发不该出现共享音频目录(音频本来就只属于它一个)。 if (entry.type === "single" && (await exists(join(root, "audios", "_shared")))) { result.problems.push(`${dir}: 单档分发里不该有 audios/_shared`); } // 合集类分发必须有合集根 audios/(bgm 要能选到任一壁纸的音源)—— // 但**整份分发一个音源都没有**时不该要求它:那只会逼出一个空的 audios/ 目录 // (比如纯场景壁纸、音源还没抓的页面)。判据是 preset.js 里有没有音频落点。 const hasAudio = ( await Promise.all( presetModules.map(async (presetRel) => /[/\\]audios[/\\]/.test(await readFile(join(root, presetRel), "utf8"))), ) ).some(Boolean); if (entry.type !== "single" && hasAudio && !(await exists(join(root, "audios")))) { result.problems.push(`${dir}: 合集分发缺少合集根 audios/`); } // 自包含包(`pnpm build sim` 产出):它必须在 `file://` 下**双击就能开**, // 所以这里查的是"页面上还有没有任何外部依赖"。 // // 只在**真正的标签与赋值**上判定,不在整页文本上做正则——spine-player 是整份内联进来的, // 它内部带着官网文档链接与一段编辑器示例模板(里面有 `