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:
@@ -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)`);
|
||||
@@ -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} 条引用)`);
|
||||
@@ -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] 点击特效全部通过");
|
||||
@@ -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)`
|
||||
);
|
||||
Reference in New Issue
Block a user