Files
halo-theme-WarmIsland/scripts/package.mjs
T
nxxy335 dda86294b0 fix: 按 Halo 应用市场审核意见整改外部资源与隐私(v1.2.5 修订)
- 默认零第三方自动请求:移除 21 个模板的 preconnect/dns-prefetch、PrismJS+autoloader、qrcode-generator 及内置一言/IP 定位 API
- 一言与代码高亮配色改为自托管;欢迎弹窗自定义 IP 接口改为 opt-in;微信分享改为复制链接;LightGallery 按 GPLv3 使用
- README 新增「外部资源与隐私」「验证与回归测试」章节,修正无效链接并补充字体 OFL 来源;同步 docs 与修正虚构的目录树
- friends 等 12 处 #strings.substring 加空值保护;logo 由两份 1.5MB 压缩去重为单份 146KB
- 新增 pnpm audit:external 与打包前置合规审计;制品补齐 34 个 docs 文档与预览图
2026-11-11 14:37:09 +08:00

258 lines
9.1 KiB
JavaScript
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.
/**
* 打包主题安装包: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";
import { auditTemplates, auditReadmeLinks } from "./audit-external.mjs";
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",
/* 上架说明引用的预览图必须真实存在于制品中(审核指南 4.6 / 2.2) */
"WarmIsland Preview.webp",
"pnpm-lock.yaml",
"pnpm-workspace.yaml",
];
const INCLUDE_DIRS = ["templates"];
/* README 引用的 docs/*.md 也要随包分发,避免制品内出现死链 */
const INCLUDE_DOCS = ["docs"];
const EXCLUDE_DIRS = new Set([
".git", "node_modules", "src", "dist", "compat", ".astro", ".vitepress",
]);
/* ---------------- 构建产物前置校验 ----------------
* 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, opts = {}) {
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, opts);
} else {
/* docs 只收 Markdown 源文件,不收 VitePress 构建产物 / 依赖 README */
if (opts.mdOnly && !entry.name.endsWith(".md")) continue;
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);
}
for (const dir of INCLUDE_DOCS) {
const full = path.join(root, dir);
if (fs.existsSync(full)) walk(full, dir, { mdOnly: true });
}
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}`);
}
/* ---------------- 上架合规前置校验 ----------------
* 1. 主题不得出现未披露的第三方自动请求 / 远程动态加载(审核指南 1.4 / 4.4.4)
* 2. README 的相对链接必须在制品内真实存在(审核指南 4.6 / 2.2)
* 3. logo 只保留一份压缩版,避免打包两份 1.5MB 大图
*/
const templateAudit = auditTemplates(templatesDir);
for (const problem of templateAudit.problems) {
console.error(`[package] ${problem}`);
}
if (templateAudit.problems.length) {
fail("外部资源审计未通过:模板仍存在第三方远程加载 / 预连接");
}
const linkAudit = auditReadmeLinks(new Set(entries.map((entry) => entry.name)));
for (const problem of linkAudit.problems) {
console.error(`[package] ${problem}`);
}
if (linkAudit.problems.length) {
fail("README 存在指向制品中不存在文件的相对链接");
}
if (fs.existsSync(path.join(templatesDir, "logo.png"))) {
fail("templates/logo.png 仍存在:应只保留压缩后的 templates/assets/logo.png");
}
const logoFile = path.join(templatesDir, "assets", "logo.png");
if (fs.existsSync(logoFile) && fs.statSync(logoFile).size > 300 * 1024) {
fail("templates/assets/logo.png 超过 300KB,请先压缩再打包");
}
console.log(
`[package] 合规审计通过(${templateAudit.htmlCount} 个模板、${linkAudit.checked} 个相对链接、logo 单份压缩)`
);
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)`
);