Files
halo-theme-WarmIsland/scripts/package.mjs
T
nxxy335 6c612911b3 fix: 修复升级后首页 404(空壳安装包缺少 templates)
- 重新构建并产出含 21 个模板的完整安装包 v1.2.4
- package.mjs 增加模板/资源目录/版本号前置校验,禁止产出空壳包
- 新增 pnpm release 一键发版;修正 README、开发指南与 FAQ 的构建打包说明
2026-11-10 11:12:25 +08:00

213 lines
7.1 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.
/**
* 打包主题安装包:dist/warm-island-<version>.zip
*
* 安装包结构与 Halo 主题目录一致(根目录直接放 theme.yaml / settings.yaml /
* templates),与历史版本安装包保持相同布局,可直接上传安装。
*
* 仅依赖 Node 内置模块,无需额外依赖。
*/
import fs from "node:fs";
import path from "node:path";
import zlib from "node:zlib";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
/** 读取 theme.yaml 中的版本号(避免手抄版本号不一致) */
function readThemeVersion() {
const yaml = fs.readFileSync(path.join(root, "theme.yaml"), "utf8");
const match = yaml.match(/^\s*version:\s*["']?([^"'\n]+)["']?\s*$/m);
if (!match) {
throw new Error("theme.yaml 中未找到 version 字段");
}
return match[1].trim();
}
/* ---------------- CRC32 ---------------- */
const CRC_TABLE = (() => {
const table = new Int32Array(256);
for (let i = 0; i < 256; i++) {
let c = i;
for (let k = 0; k < 8; k++) {
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
}
table[i] = c;
}
return table;
})();
function crc32(buf) {
let c = 0xffffffff;
for (let i = 0; i < buf.length; i++) {
c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
}
return (c ^ 0xffffffff) >>> 0;
}
/* ---------------- ZIP 写入 ---------------- */
function dosDateTime(date = new Date()) {
const time =
((date.getHours() & 0x1f) << 11) |
((date.getMinutes() & 0x3f) << 5) |
((date.getSeconds() / 2) & 0x1f);
const day =
(((date.getFullYear() - 1980) & 0x7f) << 9) |
(((date.getMonth() + 1) & 0xf) << 5) |
(date.getDate() & 0x1f);
return { time, day };
}
function buildZip(entries, outFile) {
const chunks = [];
const central = [];
let offset = 0;
for (const entry of entries) {
const nameBuf = Buffer.from(entry.name, "utf8");
const raw = entry.data;
const crc = crc32(raw);
const compressed = zlib.deflateRawSync(raw, { level: 9 });
const useDeflate = compressed.length < raw.length;
const body = useDeflate ? compressed : raw;
const method = useDeflate ? 8 : 0;
const { time, day } = dosDateTime(entry.date);
const local = Buffer.alloc(30);
local.writeUInt32LE(0x04034b50, 0);
local.writeUInt16LE(20, 4); // version needed
local.writeUInt16LE(0x0800, 6); // UTF-8 名称
local.writeUInt16LE(method, 8);
local.writeUInt16LE(time, 10);
local.writeUInt16LE(day, 12);
local.writeUInt32LE(crc, 14);
local.writeUInt32LE(body.length, 18);
local.writeUInt32LE(raw.length, 22);
local.writeUInt16LE(nameBuf.length, 26);
local.writeUInt16LE(0, 28);
chunks.push(local, nameBuf, body);
const cd = Buffer.alloc(46);
cd.writeUInt32LE(0x02014b50, 0);
cd.writeUInt16LE(20, 4); // version made by
cd.writeUInt16LE(20, 6); // version needed
cd.writeUInt16LE(0x0800, 8);
cd.writeUInt16LE(method, 10);
cd.writeUInt16LE(time, 12);
cd.writeUInt16LE(day, 14);
cd.writeUInt32LE(crc, 16);
cd.writeUInt32LE(body.length, 20);
cd.writeUInt32LE(raw.length, 24);
cd.writeUInt16LE(nameBuf.length, 28);
cd.writeUInt16LE(0, 30); // extra
cd.writeUInt16LE(0, 32); // comment
cd.writeUInt16LE(0, 34); // disk start
cd.writeUInt16LE(0, 36); // internal attrs
cd.writeUInt32LE(0, 38); // external attrs
cd.writeUInt32LE(offset, 42);
central.push(Buffer.concat([cd, nameBuf]));
offset += local.length + nameBuf.length + body.length;
}
const centralBuf = Buffer.concat(central);
const eocd = Buffer.alloc(22);
eocd.writeUInt32LE(0x06054b50, 0);
eocd.writeUInt16LE(0, 4);
eocd.writeUInt16LE(0, 6);
eocd.writeUInt16LE(entries.length, 8);
eocd.writeUInt16LE(entries.length, 10);
eocd.writeUInt32LE(centralBuf.length, 12);
eocd.writeUInt32LE(offset, 16);
eocd.writeUInt16LE(0, 20);
fs.mkdirSync(path.dirname(outFile), { recursive: true });
fs.writeFileSync(outFile, Buffer.concat([...chunks, centralBuf, eocd]));
}
/* ---------------- 收集文件 ---------------- */
const INCLUDE_ROOT = [
"theme.yaml",
"settings.yaml",
"LICENSE",
"README.md",
"pnpm-lock.yaml",
"pnpm-workspace.yaml",
];
const INCLUDE_DIRS = ["templates"];
const EXCLUDE_DIRS = new Set([".git", "node_modules", "src", "dist", "compat", ".astro"]);
/* ---------------- 构建产物前置校验 ----------------
* templates/ 被 .gitignore 忽略,克隆仓库或清理工作区后默认不存在。
* 旧逻辑里 walk() 对不存在的目录只是静默跳过,于是会打出只含
* theme.yaml / settings.yaml 的"空壳包";上传到 Halo 后旧主题被覆盖,
* 找不到 index 模板,首页直接 404「Template index was not found.」。
* 这里 fail fast,禁止产出这种包。
*/
function fail(message) {
console.error(`[package] ${message}`);
console.error("[package] 请先执行 pnpm build(或直接 pnpm release)生成完整模板产物后再打包。");
process.exit(1);
}
const templatesDir = path.join(root, "templates");
if (!fs.existsSync(templatesDir)) fail("未找到 templates/ 目录");
const htmlTemplates = fs.readdirSync(templatesDir).filter((name) => name.endsWith(".html"));
for (const required of ["index.html", "post.html", "page.html", "archives.html"]) {
if (!htmlTemplates.includes(required)) fail(`缺少必需模板 templates/${required}`);
}
if (htmlTemplates.length < 10) {
fail(`模板数量异常(仅 ${htmlTemplates.length} 个 .html),疑似构建不完整`);
}
if (!fs.existsSync(path.join(templatesDir, "assets"))) {
fail("未找到 templates/assets/ 静态资源目录");
}
const entries = [];
for (const name of INCLUDE_ROOT) {
const full = path.join(root, name);
if (fs.existsSync(full)) {
entries.push({ name, data: fs.readFileSync(full), date: fs.statSync(full).mtime });
}
}
function walk(dir, prefix) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const abs = path.join(dir, entry.name);
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
if (EXCLUDE_DIRS.has(entry.name)) continue;
walk(abs, rel);
} else {
entries.push({ name: rel, data: fs.readFileSync(abs), date: fs.statSync(abs).mtime });
}
}
}
for (const dir of INCLUDE_DIRS) {
const full = path.join(root, dir);
if (fs.existsSync(full)) walk(full, dir);
}
if (!entries.some((entry) => entry.name === "templates/index.html")) {
fail("打包清单中缺少 templates/index.html");
}
const version = readThemeVersion();
const pkgVersion = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8")).version;
if (pkgVersion !== version) {
fail(`版本号不一致:package.json=${pkgVersion},theme.yaml=${version}`);
}
const outFile = path.join(root, "dist", `warm-island-${version}.zip`);
buildZip(entries, outFile);
const assetCount = entries.filter((entry) => entry.name.startsWith("templates/assets/")).length;
const sizeMB = (fs.statSync(outFile).size / 1024 / 1024).toFixed(2);
console.log(
`[package] dist/warm-island-${version}.zip(${entries.length} 个文件:` +
`${htmlTemplates.length} 个模板 / ${assetCount} 个资源,${sizeMB}MB)`
);