// 自定义侧栏工具保存前静态扫描:纯字符串启发式检查,不依赖 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; }