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

378
frontend/lib/widgetLint.ts Normal file
View File

@@ -0,0 +1,378 @@
// 自定义侧栏工具保存前静态扫描:纯字符串启发式检查,不依赖 AST / 第三方库。
// 扫描时先做「等长掩码」——注释、字符串、正则字面量被替换为空格(换行保留),
// 因此写在注释/字符串里的关键词不会误报,行列号仍按原文计算。
//
// error(命中禁止保存):
// JS document / ownerDocument 上的全局元素查找 API
// CSS position: fixed
// CSS 以 body / html / * 开头的全局选择器
// warn(提醒确认后可保存):
// JS setInterval((仅当整份代码没有任何 clearInterval 调用时才报;
// 出现 clearInterval 即视为作者已自行管理定时器生命周期,避免对规范写法的误导性提醒)
// CSS transform ... scale(
//
// 已知取舍:HTML 内联事件属性(onclick="" 等)本轮不扫描;模板字符串 ${} 内的代码不扫描。
export type WidgetLintField = "js" | "css" | "html";
export type WidgetLintLevel = "error" | "warn";
export interface WidgetLintIssue {
level: WidgetLintLevel;
field: WidgetLintField;
/** 机器可读规则码 */
code: string;
/** 1-based 行号(按原始文本) */
line: number;
/** 1-based 列号(按原始文本) */
column: number;
message: string;
}
export interface WidgetLintInput {
html?: string;
css?: string;
js?: string;
}
/* ─────────────────────────── 通用工具 ─────────────────────────── */
/** 把 [0, n) 内的字符替换为空格,换行保留(保证掩码与原文等长、行列号一致) */
function blankRange(mask: string[], from: number, to: number, src: string): void {
for (let k = from; k < to && k < mask.length; k++) {
mask[k] = src[k] === "\n" ? "\n" : " ";
}
}
interface LineMap {
lineStarts: number[];
}
function buildLineMap(src: string): LineMap {
const lineStarts = [0];
for (let i = 0; i < src.length; i++) {
if (src[i] === "\n") lineStarts.push(i + 1);
}
return { lineStarts };
}
function locate(map: LineMap, index: number): { line: number; column: number } {
const { lineStarts } = map;
// 二分:最后一个 <= index 的行首
let lo = 0;
let hi = lineStarts.length - 1;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (lineStarts[mid] <= index) lo = mid;
else hi = mid - 1;
}
return { line: lo + 1, column: index - lineStarts[lo] + 1 };
}
/* ─────────────────────────── JS 掩码 ─────────────────────────── */
const REGEX_PREV_PUNCT = new Set(["(", ",", "=", ":", "[", "!", "&", "|", "?", "{", ";", "+", "-", "*", "%", "^", "~", "<", ">"]);
const REGEX_PREV_KEYWORDS = new Set([
"return", "typeof", "instanceof", "in", "of", "new", "delete", "void", "case", "do", "else", "yield", "await",
]);
// 判断 slashIndex 处的 / 更可能是正则起始还是除号
function regexAllowedBefore(src: string, slashIndex: number): boolean {
let j = slashIndex - 1;
while (j >= 0 && src[j] !== "\n" && /\s/.test(src[j])) j--;
if (j < 0 || src[j] === "\n") return true;
if (REGEX_PREV_PUNCT.has(src[j])) return true;
let k = j;
while (k >= 0 && /[\w$]/.test(src[k])) k--;
const word = src.slice(k + 1, j + 1);
return REGEX_PREV_KEYWORDS.has(word);
}
/** 等长掩码 JS:注释、字符串、正则字面量 → 空格(换行保留) */
export function maskJavaScript(src: string): string {
const mask = src.split("");
const n = src.length;
let i = 0;
while (i < n) {
const ch = src[i];
const next = src[i + 1];
if (ch === "/" && next === "/") {
let j = i + 2;
while (j < n && src[j] !== "\n") j++;
blankRange(mask, i, j, src);
i = j;
continue;
}
if (ch === "/" && next === "*") {
const close = src.indexOf("*/", i + 2);
const stop = close === -1 ? n : close + 2;
blankRange(mask, i, stop, src);
i = stop;
continue;
}
if (ch === '"' || ch === "'" || ch === "`") {
const quote = ch;
let j = i + 1;
while (j < n) {
if (src[j] === "\\") j += 2;
else if (src[j] === quote) { j++; break; }
else j++;
}
// 模板字符串 ${} 内的表达式不解析(接受该处的漏报)
blankRange(mask, i, Math.min(j, n), src);
i = j;
continue;
}
if (ch === "/" && regexAllowedBefore(src, i)) {
let j = i + 1;
let inClass = false;
while (j < n) {
const c = src[j];
if (c === "\\") j += 2;
else if (c === "\n") break; // 正则不能跨行,容错按除法放弃掩码
else if (c === "[") inClass = true;
else if (c === "]") inClass = false;
else if (c === "/" && !inClass) { j++; break; }
else j++;
}
while (j < n && /[\w$]/.test(src[j])) j++; // flags
blankRange(mask, i, j, src);
i = j;
continue;
}
i++;
}
return mask.join("");
}
/* ─────────────────────────── CSS 掩码与解析 ─────────────────────────── */
/** 等长掩码 CSS:块注释与字符串 → 空格(换行保留) */
export function maskCss(src: string): string {
const mask = src.split("");
const n = src.length;
let i = 0;
while (i < n) {
if (src[i] === "/" && src[i + 1] === "*") {
const close = src.indexOf("*/", i + 2);
const stop = close === -1 ? n : close + 2;
blankRange(mask, i, stop, src);
i = stop;
continue;
}
if (src[i] === '"' || src[i] === "'") {
const quote = src[i];
let j = i + 1;
while (j < n) {
if (src[j] === "\\") j += 2;
else if (src[j] === quote) { j++; break; }
else j++;
}
blankRange(mask, i, Math.min(j, n), src);
i = j;
continue;
}
i++;
}
return mask.join("");
}
/** 从 openBrace 后一位起找匹配的 }(掩码文本,引号/注释已是空格) */
function matchBrace(src: string, from: number, to: number): number {
let depth = 1;
for (let i = from; i < to; i++) {
if (src[i] === "{") depth++;
else if (src[i] === "}") {
depth--;
if (depth === 0) return i;
}
}
return to;
}
/** 选择器按顶层逗号切分,保留相对偏移;忽略 () / [] 内的逗号 */
function splitSelectors(head: string): { sel: string; offset: number }[] {
const out: { sel: string; offset: number }[] = [];
let depth = 0;
let start = 0;
for (let i = 0; i < head.length; i++) {
const c = head[i];
if (c === "(" || c === "[") depth++;
else if (c === ")" || c === "]") depth = Math.max(0, depth - 1);
else if (c === "," && depth === 0) {
out.push({ sel: head.slice(start, i), offset: start });
start = i + 1;
}
}
out.push({ sel: head.slice(start), offset: start });
return out;
}
const NESTING_AT = new Set(["@media", "@supports", "@container", "@layer"]);
/* ─────────────────────────── 规则实现 ─────────────────────────── */
const LOOKUP_METHODS = "getElementById|querySelectorAll|querySelector|getElementsByClassName|getElementsByTagName|getElementsByName";
const GLOBAL_LOOKUP_RE = new RegExp(`\\bdocument\\s*\\.\\s*(?:${LOOKUP_METHODS})\\s*\\(`, "g");
const OWNER_LOOKUP_RE = new RegExp(`\\.ownerDocument\\s*\\.\\s*(?:${LOOKUP_METHODS})\\s*\\(`, "g");
const SET_INTERVAL_RE = /\bsetInterval\s*\(/g;
const CLEAR_INTERVAL_RE = /\bclearInterval\s*\(/;
function lintJs(js: string, issues: WidgetLintIssue[]): void {
const map = buildLineMap(js);
const masked = maskJavaScript(js);
const pushAt = (
level: WidgetLintLevel,
code: string,
index: number,
message: string
) => {
const { line, column } = locate(map, index);
issues.push({ level, field: "js", code, line, column, message });
};
for (const re of [GLOBAL_LOOKUP_RE, OWNER_LOOKUP_RE]) {
re.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = re.exec(masked)) !== null) {
pushAt(
"error",
"no-global-dom-lookup",
m.index,
"禁止从 document/ownerDocument 全局查找元素:同页多个同款工具会互相取错元素。请改用 root.querySelector / root.getElementById 等从 root 出发的查找。"
);
}
}
// 代码里已出现 clearInterval 调用(含 safeInterval 等封装内部、自终止定时器),
// 视为作者已处理清理,不再提醒;完全没有清理痕迹时才逐处 warn。
if (!CLEAR_INTERVAL_RE.test(masked)) {
SET_INTERVAL_RE.lastIndex = 0;
let im: RegExpExecArray | null;
while ((im = SET_INTERVAL_RE.exec(masked)) !== null) {
pushAt(
"warn",
"warn-set-interval",
im.index,
"setInterval 未发现配对的 clearInterval:请保存返回的 id,并在 registerCleanup 中清理,否则离开页面后定时器仍在执行。"
);
}
}
}
function lintCss(css: string, issues: WidgetLintIssue[]): void {
const map = buildLineMap(css);
const src = maskCss(css);
const n = src.length;
const pushAt = (
level: WidgetLintLevel,
code: string,
index: number,
message: string
) => {
const { line, column } = locate(map, index);
issues.push({ level, field: "css", code, line, column, message });
};
const checkSelectors = (head: string, headStart: number) => {
for (const part of splitSelectors(head)) {
const sel = part.sel.trim();
if (!sel) continue;
const abs = headStart + part.offset + (part.sel.length - part.sel.trimStart().length);
if (sel[0] === "*") {
pushAt(
"error",
"no-global-selector",
abs,
"禁止以 * 开头的全局选择器。Shadow DOM 内样式天然隔离,请直接写具体类名。"
);
continue;
}
const gm = sel.match(/^(html|body)\b/i);
if (gm) {
pushAt(
"error",
"no-global-selector",
abs,
`禁止以 ${gm[1].toLowerCase()} 开头的全局选择器:在 Shadow DOM 内它匹配不到任何元素。请改用 .tool-root(CSS 变量也定义在 .tool-root 上)。`
);
}
}
};
const checkDeclarations = (body: string, bodyStart: number) => {
let cursor = 0;
for (const decl of body.split(";")) {
const rel = cursor;
cursor += decl.length + 1;
const colon = decl.indexOf(":");
if (colon < 0) continue;
const prop = decl.slice(0, colon).trim().toLowerCase();
const val = decl.slice(colon + 1).trim();
if (!prop) continue;
const propAbs = bodyStart + rel + decl.indexOf(prop);
if (prop === "position" && /^fixed\b/i.test(val)) {
pushAt(
"error",
"no-fixed-position",
propAbs,
"禁止 position: fixed:会脱离工具容器、盖住全站。请改用 absolute(相对 .tool-root 定位)。"
);
}
if ((prop === "transform" || prop.endsWith("-transform")) && /\bscale\s*\(/i.test(val)) {
pushAt(
"warn",
"warn-scale-transform",
propAbs,
"transform: scale() 可能溢出容器引出版块滚动条;.tool-root 已设 overflow:hidden 裁切,请确认动画不依赖溢出区域可见。"
);
}
}
};
// 递归下降解析规则块;@keyframes 内部整块跳过(动画里的 transform:scale 是正常用法)
const parse = (from: number, to: number) => {
let i = from;
while (i < to) {
const headStart = i;
let j = i;
while (j < to && src[j] !== "{" && src[j] !== "}" && src[j] !== ";") j++;
const head = src.slice(i, j);
if (j >= to) break;
if (src[j] === ";") {
i = j + 1;
continue;
}
if (src[j] === "}") {
i = j + 1; // 容错:多余闭括号
continue;
}
const close = matchBrace(src, j + 1, to);
const headTrim = head.trim();
if (headTrim.startsWith("@")) {
const at = headTrim.split(/[\s(]/)[0].toLowerCase();
if (NESTING_AT.has(at)) parse(j + 1, close);
// @keyframes / @font-face / 其他 at 块:整体跳过
} else {
checkSelectors(head, headStart);
checkDeclarations(src.slice(j + 1, close), j + 1);
}
i = close + 1;
}
};
parse(0, n);
}
/**
* 保存前静态扫描。返回问题列表(按字段、源码位置排序);
* 有 error 时调用方应阻止保存,仅有 warn 时需用户二次确认。
*/
export function lintWidgetCode(input: WidgetLintInput): WidgetLintIssue[] {
const issues: WidgetLintIssue[] = [];
// html 本轮不产出规则;css 问题排在 js 之前
if (input.css) lintCss(input.css, issues);
if (input.js) lintJs(input.js, issues);
return issues;
}