fix: 修复升级后整站无样式与点击特效失效

- 旧哈希静态资源随版本升级被删除,被缓存的 HTML 仍请求旧文件名导致
  FileNotFoundException 500,补 compat/assets 历史资源并在构建时合并
- postbuild 增加静态资源引用校验,缺失即构建失败
- ClickEffect 改为常驻配置节点 + data 属性驱动,避免配置未落库时静默失效;
  强制回流修正 transition 不触发;特效层挂到 body 下避免被导航栏遮挡
- 新增 scripts: postbuild/snapshot-assets/package/smoke-clickfx
- 版本 1.2.1
This commit is contained in:
2026-09-27 16:27:51 +08:00
parent a127d5a314
commit 125d0592c9
42 changed files with 951 additions and 123 deletions
+172
View File
@@ -0,0 +1,172 @@
/**
* 打包主题安装包: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"]);
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);
}
const version = readThemeVersion();
const outFile = path.join(root, "dist", `warm-island-${version}.zip`);
buildZip(entries, outFile);
const sizeMB = (fs.statSync(outFile).size / 1024 / 1024).toFixed(2);
console.log(`[package] dist/warm-island-${version}.zip(${entries.length} 个文件,${sizeMB}MB)`);
+80
View File
@@ -0,0 +1,80 @@
/**
* 构建后置处理:
* 1. 复制 logo.png 到 assets(保留历史行为)
* 2. 合并 compat/assets 下的历史版本静态资源,避免站点/代理缓存中的旧 HTML
* 仍然引用上一版本的哈希文件名时出现 500(FileNotFoundException)
* 3. 校验所有 templates/*.html 引用的静态资源真实存在,缺失即构建失败
*/
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 assetsDir = path.join(root, "templates", "assets");
const compatDir = path.join(root, "compat", "assets");
const templatesDir = path.join(root, "templates");
fs.mkdirSync(assetsDir, { recursive: true });
// 1. logo
const logoSrc = path.join(templatesDir, "logo.png");
if (fs.existsSync(logoSrc)) {
fs.copyFileSync(logoSrc, path.join(assetsDir, "logo.png"));
}
// 2. 历史资源合并(已存在的同名文件以当前构建为准,不覆盖)
if (fs.existsSync(compatDir)) {
let copied = 0;
let skipped = 0;
for (const entry of fs.readdirSync(compatDir, { withFileTypes: true })) {
if (!entry.isFile()) continue;
const src = path.join(compatDir, entry.name);
const dst = path.join(assetsDir, entry.name);
if (fs.existsSync(dst)) {
skipped++;
continue;
}
fs.copyFileSync(src, dst);
copied++;
}
console.log(`[compat] 历史静态资源:复制 ${copied} 个,当前构建已覆盖 ${skipped} 个`);
}
// 3. 资源引用校验
const missing = [];
const checked = new Set();
/** 主题静态资源前缀,必须与 astro.config.mjs 的 base 保持一致 */
const prefixes = ["/themes/warm-island/assets/"];
/** Thymeleaf 的 @{...} 链接表达式同样需要校验 */
const REFERENCE_RE =
/(?:href|src)=["']([^"']*?\/(?:themes\/[^/]+)\/assets\/[^"']+?)["']/g;
for (const file of fs.readdirSync(templatesDir)) {
if (!file.endsWith(".html")) continue;
const html = fs.readFileSync(path.join(templatesDir, file), "utf8");
REFERENCE_RE.lastIndex = 0;
let match;
while ((match = REFERENCE_RE.exec(html)) !== null) {
let ref = match[1];
if (ref.includes("@{")) continue; // 由 Halo 运行时解析,跳过
for (const prefix of prefixes) {
if (!ref.startsWith(prefix)) continue;
const rel = ref.slice(prefix.length).split("#")[0].split("?")[0];
const key = `${file}:${rel}`;
if (checked.has(key)) continue;
checked.add(key);
if (!fs.existsSync(path.join(assetsDir, rel))) {
missing.push(`${file} -> ${ref}`);
}
}
}
}
if (missing.length > 0) {
console.error(`[verify] 发现 ${missing.length} 处静态资源缺失:`);
for (const item of missing) console.error(` - ${item}`);
process.exit(1);
}
console.log(`[verify] 静态资源引用校验通过(${checked.size} 条引用)`);
+154
View File
@@ -0,0 +1,154 @@
/**
* 点击特效脚本冒烟测试(Node 内置模块,无第三方依赖)。
*
* 直接从构建产物 templates/index.html 中取出特效脚本,放进桩 DOM 里执行,
* 校验:启用/禁用判定、拖尾与点击分支、节点生成与定位。
*
* 这类 bug 的共同点是"不报错但没效果",靠读代码很难发现,所以固化成测试。
* 用法:npm run build && npm run smoke
*/
import fs from "node:fs";
import path from "node:path";
import vm from "node:vm";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const template = path.join(root, "templates", "index.html");
if (!fs.existsSync(template)) {
console.error("[smoke] 未找到 templates/index.html,请先执行 npm run build");
process.exit(1);
}
const html = fs.readFileSync(template, "utf8");
const anchor = html.indexOf('id="wi-click-effect"');
if (anchor === -1) {
console.error("[smoke] 模板中未找到 #wi-click-effect 节点");
process.exit(1);
}
const scriptOpen = html.indexOf("<script", anchor);
const scriptStart = html.indexOf(">", scriptOpen) + 1;
const scriptEnd = html.indexOf("</script>", scriptStart);
const code = html.slice(scriptStart, scriptEnd);
const failures = [];
function makeEl(tag) {
return {
tagName: tag,
className: "",
innerHTML: "",
textContent: "",
children: [],
parentNode: null,
style: {},
_attrs: {},
get offsetWidth() {
return 1;
},
getAttribute(name) {
return Object.prototype.hasOwnProperty.call(this._attrs, name) ? this._attrs[name] : null;
},
appendChild(child) {
if (child.parentNode) return child;
child.parentNode = this;
this.children.push(child);
return child;
},
removeChild(child) {
const i = this.children.indexOf(child);
if (i >= 0) {
this.children.splice(i, 1);
child.parentNode = null;
}
return child;
},
};
}
function run(attrs, expectEnabled, label) {
const cfg = makeEl("div");
Object.assign(cfg._attrs, attrs);
const body = makeEl("body");
const listeners = {};
const sandbox = {
document: {
getElementById: (id) => (id === "wi-click-effect" ? cfg : null),
createElement: makeEl,
body,
addEventListener(type, fn) {
(listeners[type] = listeners[type] || []).push(fn);
},
},
window: {},
requestAnimationFrame: (fn) => fn(),
setTimeout: () => 0,
};
vm.createContext(sandbox);
vm.runInContext(code, sandbox);
const hasListener = Object.keys(listeners).length > 0;
if (hasListener !== expectEnabled) {
failures.push(`${label}:期望 ${expectEnabled ? "注册监听" : "不注册监听"},实际相反`);
return;
}
if (!expectEnabled) {
console.log(` ✔ ${label} -> 已跳过`);
return;
}
const layer = body.children.find((c) => c.className === "wi-click-fx-layer");
if (!layer) {
failures.push(`${label}:未创建 .wi-click-fx-layer`);
return;
}
if (layer.children.length !== 0) {
failures.push(`${label}:初始化时特效层不应有子节点`);
return;
}
const type = attrs["data-effect"] || "(缺失,走兜底 values)";
if (listeners.click) {
listeners.click[0]({ clientX: 120, clientY: 240 });
} else if (listeners.mousemove) {
listeners.mousemove[0]({ clientX: 120, clientY: 240 });
} else {
failures.push(`${label}:未注册 click 或 mousemove 监听`);
return;
}
const spawned = layer.children;
if (spawned.length === 0) {
failures.push(`${label}:effect=${type} 触发后未生成任何特效节点`);
return;
}
const first = spawned[0];
if (!String(first.className).startsWith("wi-click-fx ")) {
failures.push(`${label}:特效节点缺少 wi-click-fx 基类(${first.className})`);
}
if (first.style.left !== "120px" || first.style.top !== "240px") {
failures.push(`${label}:定位不正确(${first.style.left}, ${first.style.top})`);
}
console.log(` ✔ ${label} effect=${type} -> ${spawned.length} 个节点,className=${first.className}`);
}
console.log("禁用场景:");
run({ "data-enabled": "false", "data-effect": "values" }, false, "显式关闭");
run({ "data-effect": "values" }, false, "配置缺失");
run({ "data-enabled": "true", "data-effect": "off" }, false, "类型选了「关闭」");
console.log("点击场景:");
for (const effect of ["values", "hearts", "fireworks", "ripples", "particles", "stars"]) {
run({ "data-enabled": "true", "data-effect": effect }, true, effect);
}
console.log("兜底与拖尾:");
run({ "data-enabled": "true" }, true, "type 缺省");
run({ "data-enabled": "true", "data-effect": "star-trail" }, true, "star-trail");
run({ "data-enabled": "true", "data-effect": "particle-trail" }, true, "particle-trail");
if (failures.length > 0) {
console.error("\n[smoke] 失败:");
for (const f of failures) console.error(" - " + f);
process.exit(1);
}
console.log("\n[smoke] 点击特效全部通过");
+63
View File
@@ -0,0 +1,63 @@
/**
* 把当前构建产出的哈希静态资源快照进 compat/assets。
*
* 为什么要这么做:
* Astro 给 CSS/JS 加内容哈希,主题升级后旧文件会被删除。若站点缓存或反向代理
* (Halo 页面缓存 / Nginx / CDN)里还留着上一版本的 HTML,它仍然会请求旧哈希
* 文件,Halo 找不到就直接抛 FileNotFoundException 返回 500,表现为"整站没样式"。
* 把每个版本的哈希文件都留一份,旧 HTML 就永远不会请求失败。
*
* 用法:npm run build && npm run snapshot(随后把 compat/assets 的新增文件提交进仓库)
*/
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 assetsDir = path.join(root, "templates", "assets");
const compatDir = path.join(root, "compat", "assets");
if (!fs.existsSync(assetsDir)) {
console.error("[snapshot] 未找到 templates/assets,请先执行 npm run build");
process.exit(1);
}
fs.mkdirSync(compatDir, { recursive: true });
/** 名称固定、体积大的资源不需要进兼容层 */
const SKIP_EXACT = new Set(["logo.png"]);
const SKIP_DIRS = new Set(["fonts"]);
let added = 0;
let updated = 0;
let unchanged = 0;
for (const entry of fs.readdirSync(assetsDir, { withFileTypes: true })) {
if (entry.isDirectory()) {
if (SKIP_DIRS.has(entry.name)) continue;
// 哈希资源都在根目录,子目录(如 fonts)不参与快照
continue;
}
if (SKIP_EXACT.has(entry.name)) continue;
const src = path.join(assetsDir, entry.name);
const dst = path.join(compatDir, entry.name);
const stat = fs.statSync(src);
if (!fs.existsSync(dst)) {
fs.copyFileSync(src, dst);
added++;
} else {
const dstBuf = fs.readFileSync(dst);
const srcBuf = fs.readFileSync(src);
if (dstBuf.length !== stat.size || !dstBuf.equals(srcBuf)) {
fs.copyFileSync(src, dst);
updated++;
} else {
unchanged++;
}
}
}
console.log(
`[snapshot] 新增 ${added} / 更新 ${updated} / 无变化 ${unchanged} 个历史资源(compat/assets)`
);