feat: 书库导入导出/图片变体/书籍搜索/小组件运行时等
新增: - 书库导入导出(library_import/library_export)及测试 - 图片变体生成(image_variants)与响应式图片(responsiveImage) - 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件 - 小组件运行时(widgetRuntime)与静态检查(widgetLint) - 上传缓存中间件(upload_cache)与字体 CSS 提取脚本 其它: - 后端 handlers/services 全量调整 - 前端页面、组件、库函数与配置更新
This commit is contained in:
378
frontend/lib/widgetLint.ts
Normal file
378
frontend/lib/widgetLint.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user