feat: 书库导入导出/图片变体/书籍搜索/小组件运行时等

新增:
- 书库导入导出(library_import/library_export)及测试
- 图片变体生成(image_variants)与响应式图片(responsiveImage)
- 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件
- 小组件运行时(widgetRuntime)与静态检查(widgetLint)
- 上传缓存中间件(upload_cache)与字体 CSS 提取脚本

其它:
- 后端 handlers/services 全量调整
- 前端页面、组件、库函数与配置更新
This commit is contained in:
2026-10-01 03:06:05 +08:00
parent 7fbd6ba699
commit ff2ab286fb
141 changed files with 7527 additions and 1407 deletions

View File

@@ -0,0 +1,218 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { lintWidgetCode, type WidgetLintIssue } from "./widgetLint.ts";
const codes = (issues: WidgetLintIssue[]) => issues.map((i) => i.code);
describe("lintWidgetCode - JS 全局查找", () => {
it("document 上的六种查找 API 全部报 error", () => {
const js = [
'document.getElementById("a");',
'document.querySelector(".a");',
'document.querySelectorAll(".a");',
'document.getElementsByClassName("a");',
'document.getElementsByTagName("div");',
'document.getElementsByName("a");',
].join("\n");
const issues = lintWidgetCode({ js });
assert.equal(issues.length, 6);
assert.ok(issues.every((i) => i.level === "error" && i.code === "no-global-dom-lookup"));
assert.deepEqual(
issues.map((i) => i.line),
[1, 2, 3, 4, 5, 6]
);
});
it("window.document 与 ownerDocument 上的同款查找同样命中", () => {
const issues = lintWidgetCode({
js: 'window.document.querySelector(".x");\nroot.ownerDocument.getElementById("y");',
});
assert.equal(issues.length, 2);
assert.ok(issues.every((i) => i.code === "no-global-dom-lookup"));
});
it("从 root 出发的查找不报错", () => {
const issues = lintWidgetCode({
js: [
'root.querySelector(".a");',
'root.querySelectorAll(".a");',
'root.getElementById("a");',
'el.getElementsByClassName("a");',
].join("\n"),
});
assert.equal(issues.length, 0);
});
it("注释、字符串、正则字面量中的关键词不误报", () => {
const js = `
// document.getElementById("x")
/* document.querySelector(".y") */
const s1 = "document.getElementById('z')";
const s2 = 'document.querySelector(".z")';
const re = /document\\.getElementById/;
const tpl = \`document.getElementsByName("t")\`;
root.querySelector(".real");
`;
const issues = lintWidgetCode({ js });
assert.deepEqual(codes(issues), []);
});
it("行列号按原文定位", () => {
const js = "\n\n document.getElementById('x');";
const [issue] = lintWidgetCode({ js });
assert.equal(issue.line, 3);
assert.equal(issue.column, 3);
});
});
describe("lintWidgetCode - setInterval", () => {
it("只有 setInterval、全文无 clearInterval 时报 warn", () => {
const issues = lintWidgetCode({
js: "const t = setInterval(fn, 100);",
});
assert.equal(issues.length, 1);
assert.equal(issues[0].level, "warn");
assert.equal(issues[0].code, "warn-set-interval");
assert.equal(issues[0].line, 1);
});
it("setInterval 与 clearInterval 配对出现时不报", () => {
assert.deepEqual(
lintWidgetCode({ js: "const t = setInterval(fn, 100);\nclearInterval(t);" }),
[]
);
});
it("safeInterval 封装(cleanup 内清理)不报", () => {
const js = [
"function safeInterval(fn, ms) {",
" var id = setInterval(fn, ms);",
" registerCleanup(function () { clearInterval(id); });",
" return id;",
"}",
"safeInterval(render, 1000);",
].join("\n");
assert.deepEqual(lintWidgetCode({ js }), []);
});
it("clearInterval 只写在注释或字符串里不算配对,仍报 warn", () => {
const issues = lintWidgetCode({
js: 'const t = setInterval(f, 100);\n// clearInterval(t);\nconst s = "clearInterval(t)";',
});
assert.equal(issues.length, 1);
assert.equal(issues[0].code, "warn-set-interval");
assert.equal(issues[0].line, 1);
});
it("window.setInterval 同样命中;注释里的 setInterval 不报", () => {
const issues = lintWidgetCode({
js: "window.setInterval(f, 50);\n// setInterval(f, 50);",
});
assert.equal(issues.length, 1);
assert.equal(issues[0].code, "warn-set-interval");
});
it("多个无清理的 setInterval 逐处提醒", () => {
const issues = lintWidgetCode({
js: "setInterval(a, 10);\nsetInterval(b, 20);",
});
assert.deepEqual(
issues.map((i) => i.line),
[1, 2]
);
});
});
describe("lintWidgetCode - CSS position:fixed", () => {
it("position: fixed 报 error,兼容空格与 !important", () => {
const css = ".a { position: fixed; }\n.b { position:fixed !important; }\n.c { position: absolute; }";
const issues = lintWidgetCode({ css });
assert.equal(issues.length, 2);
assert.ok(issues.every((i) => i.code === "no-fixed-position" && i.level === "error"));
assert.deepEqual(
issues.map((i) => i.line),
[1, 2]
);
});
it("@media 内层规则同样检查", () => {
const issues = lintWidgetCode({ css: "@media (min-width: 100px) { .m { position: fixed; } }" });
assert.equal(issues.length, 1);
assert.equal(issues[0].code, "no-fixed-position");
});
it("注释与 content 字符串不造成误报", () => {
const css = `/* position: fixed */\n.a::after { content: "position: fixed"; color: red; }`;
assert.deepEqual(codes(lintWidgetCode({ css })), []);
});
});
describe("lintWidgetCode - CSS 全局选择器", () => {
it("body / html / * 开头的规则报 error", () => {
const issues = lintWidgetCode({
css: "body { margin: 0; }\nhtml.dark .a { color: #000; }\n* { box-sizing: border-box; }",
});
assert.equal(issues.length, 3);
assert.ok(issues.every((i) => i.code === "no-global-selector" && i.level === "error"));
});
it("选择器列表逐个判定,.body 类名与后代通配不误伤", () => {
const issues = lintWidgetCode({
css: ".body, body .card, .a *, html, *.x {}",
});
// 命中:body .card、html、*.x;.body 与 .a * 不命中
assert.equal(issues.length, 3);
});
it("@media 内层选择器同样检查", () => {
const issues = lintWidgetCode({ css: "@media (min-width: 1px) { body { color: red; } }" });
assert.equal(issues.length, 1);
assert.equal(issues[0].code, "no-global-selector");
});
});
describe("lintWidgetCode - transform: scale", () => {
it("transform 含 scale() 报 warn,支持厂商前缀与嵌套", () => {
const issues = lintWidgetCode({
css: ".a { transform: scale(1.2); }\n@media (min-width: 1px) { .b { -webkit-transform: scale(0.8); } }\n.c { transform: translateX(2px); }",
});
assert.equal(issues.length, 2);
assert.ok(issues.every((i) => i.code === "warn-scale-transform" && i.level === "warn"));
});
it("@keyframes 里的 scale 不报", () => {
const css = "@keyframes spin { from { transform: scale(0.9); } to { transform: scale(1); } }";
assert.deepEqual(codes(lintWidgetCode({ css })), []);
});
});
describe("lintWidgetCode - 综合", () => {
it("空输入与干净代码返回空列表", () => {
assert.deepEqual(lintWidgetCode({}), []);
assert.deepEqual(
lintWidgetCode({
html: '<div class="box"></div>',
css: ".box { color: red; transform: rotate(45deg); }",
js: 'root.querySelector(".box").textContent = "hi";',
}),
[]
);
});
it("css 问题排在 js 问题之前,error 与 warn 各自分级", () => {
const issues = lintWidgetCode({
css: "body { position: fixed; }\n.a { transform: scale(2); }",
js: "setInterval(f, 10);",
});
assert.deepEqual(codes(issues), [
"no-global-selector",
"no-fixed-position",
"warn-scale-transform",
"warn-set-interval",
]);
assert.deepEqual(
issues.map((i) => i.level),
["error", "error", "warn", "warn"]
);
});
});