Files
douba-app-web/scripts/build.mjs
douba 72648b4914 初始化兜爸心选 App 官网项目(构建与发布)
- src/:从线上 shop.douba.top/public/app 拉取的页面源码(v0.3.8)
- 版本号、更新日期、安装包大小改为模板变量,统一由 site.config.json 管理,
  安装包大小在构建时从 APK 实际字节数计算
- scripts/build.mjs:构建到 dist/,校验 APK、模板变量与资源引用
- scripts/deploy.mjs:与线上比对差异 → 上传 → 备份 → 原子切换 → 线上验证
- scripts/rollback.mjs、scripts/preview.mjs:回滚与本地预览
- AGENTS.md:项目说明、发版流程与服务器/nginx 约定

由 AI 助手协助完成。
2026-10-29 06:20:35 +00:00

118 lines
4.5 KiB
JavaScript
Raw Permalink 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.
// 构建: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);
}
}