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

其它:
- 后端 handlers/services 全量调整
- 前端页面、组件、库函数与配置更新
2026-10-01 03:06:05 +08:00

379 lines
13 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 自定义侧栏工具保存前静态扫描:纯字符串启发式检查,不依赖 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;
}