// 构建:src/ → dist/,渲染 {{变量}},拷入 APK,并校验页面引用的本地资源都存在 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)), '..'); const SRC = path.join(ROOT, 'src'); const DIST = path.join(ROOT, 'dist'); const RELEASES = path.join(ROOT, 'releases'); export function loadConfig() { return JSON.parse(fs.readFileSync(path.join(ROOT, 'site.config.json'), 'utf8')); } export function apkFileName(cfg) { return `douba-v${cfg.version}.apk`; } // 2026-10-29 → 2026 年 10 月 29 日 function cnDate(iso) { const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso || ''); if (!m) throw new Error(`日期格式应为 YYYY-MM-DD:${iso}`); return `${m[1]} 年 ${Number(m[2])} 月 ${Number(m[3])} 日`; } // 字节 → "61.5 MB"(十进制,和手机系统显示一致) function mb(bytes) { return `${(bytes / 1e6).toFixed(1)} MB`; } function walk(dir) { return fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => { const p = path.join(dir, e.name); return e.isDirectory() ? walk(p) : [p]; }); } function render(text, vars, file) { return text.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_, key) => { if (!(key in vars)) throw new Error(`${file}: 未定义的模板变量 {{${key}}}`); return vars[key]; }); } // 找出 HTML 的 src/href 和 CSS 的 url(),确认指向 /app 内的文件都存在 function checkRefs(files) { const missing = []; for (const file of files) { const ext = path.extname(file); if (ext !== '.html' && ext !== '.css') continue; const text = fs.readFileSync(file, 'utf8'); const re = ext === '.html' ? /\s(?:src|href|content)="([^"]+)"/g : /url\(["']?([^"')]+)["']?\)/g; for (let [, raw] of text.matchAll(re)) { raw = raw.replace(/^https:\/\/shop\.douba\.top(?=\/app)/, ''); // og:image 等写的是本站完整地址 if (/\s/.test(raw) || (!raw.includes('/') && !raw.includes('.'))) continue; // content 里的普通文本 const ref = raw.split(/[?#]/)[0]; if (!ref || /^(https?:|data:|mailto:|tel:|\/\/)/.test(raw) || /^(#|%23)/.test(raw)) continue; // %23 = 内联 SVG 里的 url(#id) let target; if (ref.startsWith('/app/')) target = path.join(DIST, ref.slice(5)); else if (ref === '/app' || ref === '/app/') target = path.join(DIST, 'index.html'); else if (ref.startsWith('/')) continue; // 站点根下的商城页面(如 /protocol.html),不归本项目管 else target = path.join(path.dirname(file), ref); if (fs.existsSync(target) && fs.statSync(target).isDirectory()) target = path.join(target, 'index.html'); if (!fs.existsSync(target)) missing.push(`${path.relative(DIST, file)} → ${raw}`); } } if (missing.length) throw new Error(`以下引用的文件不存在:\n ${missing.join('\n ')}`); } export function build() { const cfg = loadConfig(); const apkFile = apkFileName(cfg); const apkSrc = path.join(RELEASES, apkFile); if (!fs.existsSync(apkSrc)) { throw new Error(`找不到安装包 releases/${apkFile}(site.config.json 的 version 是 ${cfg.version})`); } const apkBytes = fs.statSync(apkSrc).size; const vars = { version: cfg.version, channel: cfg.channel, releaseDate: cfg.releaseDate, screenshotVersion: cfg.screenshotVersion, 'permissions.version': cfg.permissions.version, 'permissions.updatedCn': cnDate(cfg.permissions.updated), apkFile, apkSize: mb(apkBytes), }; cnDate(cfg.releaseDate); // 只做格式校验 fs.rmSync(DIST, { recursive: true, force: true }); for (const file of walk(SRC)) { const out = path.join(DIST, path.relative(SRC, file)); fs.mkdirSync(path.dirname(out), { recursive: true }); if (path.extname(file) === '.html') { fs.writeFileSync(out, render(fs.readFileSync(file, 'utf8'), vars, path.relative(ROOT, file))); } else { fs.copyFileSync(file, out); } } fs.mkdirSync(path.join(DIST, 'download'), { recursive: true }); fs.copyFileSync(apkSrc, path.join(DIST, 'download', apkFile)); checkRefs(walk(DIST)); return { cfg, apkFile, apkBytes, vars }; } if (process.argv[1] === fileURLToPath(import.meta.url)) { try { const { cfg, apkFile, vars } = build(); console.log(`构建完成 → dist/`); console.log(` 版本 v${cfg.version}(${cfg.channel}) · ${apkFile} · ${vars.apkSize} · 更新时间 ${cfg.releaseDate}`); } catch (e) { console.error(`构建失败:${e.message}`); process.exit(1); } }