From 4e71014574b0698400726b8993cda7ed34e7e3a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Mon, 14 Sep 2026 14:01:09 +0800 Subject: [PATCH 1/9] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20=E5=85=BC=E5=AE=B9?= =?UTF-8?q?=E6=80=A7=E5=88=A4=E5=AE=9A=E6=94=B6=E6=95=9B=E4=B8=BA=E4=B8=80?= =?UTF-8?q?=E5=BC=A0=E6=94=AF=E6=8C=81=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 安装页要标出「写了但不会生效」的指令与 GM 能力,但仓库里没有一份「脚本猫支持 什么」的数据:编辑器的未定义标签判定取自悬浮提示表(langs/*.ts 的 prompt 键, 一张翻译表),ESLint 的合法 header 集合取自 packages/eslint 对 eslint-plugin- userscripts 的覆盖,两者互不相识且已经漂移;GM 能力的真相只在 GMContext 注册表 里,而注册表由装饰器在 content 上下文载入时填充,安装页不可能为查一次支持性把 整套 GM 实现拉进包。 新增 src/pkg/utils/script_compat.ts 作为唯一判定来源。判定是二元的:指令要么被 脚本猫消费,要么写了也不生效。收录标准写在文件头——「会被消费」或「不消费但也 不改变脚本运行行为」,只有会改变别家管理器下脚本行为、而脚本猫没实现的指令才刻 意留在表外。GM 能力静态镜像注册表,由 GMContextApiNames() 双向守卫,新增 API 漏 进表会让测试转红;不经注册表、由上下文恒定提供的 unsafeWindow / GM_info / window.onurlchange / none 单独登记,便于审阅。 编辑器的已知标签集合改用这张表,不再随语言包重建——一条指令是否被支持与界面语言 无关。随之修正两处既有误报:@antifeature: 此前不在本地化后缀规则内被当成 未定义标签;@definition 是脚本猫自有指令却不在 ESLint 的合法 header 集合里,编辑 器会报「不是合法 userscript header」。 另加 parseMetadataLines:与 parseMetadata 共用 HEADER_BLOCK / META_LINE,逐条保留 行号。诊断必须与运行时解析看到同一批指令,否则会对着一条运行时根本没读到的行报警。 --- packages/eslint/compat-headers.d.ts | 7 + packages/eslint/compat-headers.js | 1 + src/app/service/content/gm_api/gm_context.ts | 6 + src/pages/install/compat.test.ts | 62 ++++++ src/pages/install/compat.ts | 63 ++++++ src/pkg/utils/monaco-editor/index.ts | 6 +- src/pkg/utils/monaco-editor/metadata.test.ts | 13 +- src/pkg/utils/monaco-editor/metadata.ts | 10 +- src/pkg/utils/script.test.ts | 61 +++++- src/pkg/utils/script.ts | 40 ++++ src/pkg/utils/script_compat.test.ts | 114 ++++++++++ src/pkg/utils/script_compat.ts | 208 +++++++++++++++++++ 12 files changed, 565 insertions(+), 26 deletions(-) create mode 100644 packages/eslint/compat-headers.d.ts create mode 100644 src/pages/install/compat.test.ts create mode 100644 src/pages/install/compat.ts create mode 100644 src/pkg/utils/script_compat.test.ts create mode 100644 src/pkg/utils/script_compat.ts diff --git a/packages/eslint/compat-headers.d.ts b/packages/eslint/compat-headers.d.ts new file mode 100644 index 000000000..1363abd86 --- /dev/null +++ b/packages/eslint/compat-headers.d.ts @@ -0,0 +1,7 @@ +// compat-headers.js 是给 rspack alias 用的 CommonJS 覆盖文件(见 rspack.config.ts), +// 这里只声明测试与类型检查需要的形状。 +export declare const compatMap: { + localized: Record; + unlocalized: Record; + nonFunctional: Record; +}; diff --git a/packages/eslint/compat-headers.js b/packages/eslint/compat-headers.js index 2a674d4e7..53c0d0deb 100644 --- a/packages/eslint/compat-headers.js +++ b/packages/eslint/compat-headers.js @@ -17,6 +17,7 @@ const compatMap = { storageName: [], "early-start": [], "require-css": [], + definition: [], allFrames: [], }, }; diff --git a/src/app/service/content/gm_api/gm_context.ts b/src/app/service/content/gm_api/gm_context.ts index 25bfaa109..f127a99b8 100644 --- a/src/app/service/content/gm_api/gm_context.ts +++ b/src/app/service/content/gm_api/gm_context.ts @@ -7,6 +7,12 @@ export function GMContextApiGet(name: string): ApiValue[] | undefined { return apis.get(name); } +// 已注册的全部 @grant 名。注册表由装饰器在模块载入时填充,无法静态推导, +// 供 script_compat.ts 的静态支持表做一致性守卫(新增 GM API 若漏进表会被测出来)。 +export function GMContextApiNames(): string[] { + return [...apis.keys()]; +} + function GMContextApiSet(grant: string, fnKey: string, api: any, param: ApiParam): void { // 一个 @grant 可以扩充多个 API 函数 let m: ApiValue[] | undefined = apis.get(grant); diff --git a/src/pages/install/compat.test.ts b/src/pages/install/compat.test.ts new file mode 100644 index 000000000..028987d7a --- /dev/null +++ b/src/pages/install/compat.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from "vitest"; +import type { SCMetadata } from "@App/app/repo/metadata"; +import { deriveCompatMarks } from "./compat"; + +const build = (header: string) => { + const code = `// ==UserScript==\n${header}// ==/UserScript==\n\nconsole.log(1);\n`; + const metadata: SCMetadata = {}; + for (const line of header.split("\n")) { + const m = /^\/\/ @(\S+)[ \t]*(.*)$/.exec(line); + if (!m) continue; + (metadata[m[1].toLowerCase()] ||= []).push(m[2].trim()); + } + return { code, metadata }; +}; + +describe("安装页兼容性标记", () => { + it("全部受支持时不产生任何标记", () => { + const { code, metadata } = build(`// @name X\n// @match *://a.com/*\n// @grant GM_setValue\n`); + expect(deriveCompatMarks(metadata, code)).toEqual({ grants: new Map(), tags: [] }); + }); + + it("标出脚本猫未实现的 @grant,并给出所在行", () => { + const { code, metadata } = build(`// @name X\n// @grant GM_setValue\n// @grant GM_audio\n`); + const marks = deriveCompatMarks(metadata, code); + expect(marks.grants).toEqual(new Map([["GM_audio", 4]])); + }); + + it("@grant none 不是能力请求,不标记", () => { + const { code, metadata } = build(`// @name X\n// @grant none\n`); + expect(deriveCompatMarks(metadata, code).grants.size).toBe(0); + }); + + it("标出不生效的元数据指令,@exclude-match 归到运行网站一行", () => { + const { code, metadata } = build(`// @name X\n// @match *://a.com/*\n// @exclude-match *://b.com/*\n`); + expect(deriveCompatMarks(metadata, code).tags).toEqual([{ tag: "exclude-match", group: "match", line: 4 }]); + }); + + it("归不到任何权限类别的指令落在「其他指令」组", () => { + const { code, metadata } = build(`// @name X\n// @sandbox raw\n// @top-level-await\n`); + expect(deriveCompatMarks(metadata, code).tags).toEqual([ + { tag: "sandbox", group: "other", line: 3 }, + { tag: "top-level-await", group: "other", line: 4 }, + ]); + }); + + it("同一指令写了多行只标一枚,行号取第一次出现处", () => { + const { code, metadata } = build(`// @name X\n// @sandbox a\n// @sandbox b\n`); + expect(deriveCompatMarks(metadata, code).tags).toEqual([{ tag: "sandbox", group: "other", line: 3 }]); + }); + + it("代码里定位不到时仍然成条,只是没有行号——诊断不能因为缺位置而消失", () => { + const metadata: SCMetadata = { name: ["X"], sandbox: ["raw"], grant: ["GM_audio"] }; + const marks = deriveCompatMarks(metadata, ""); + expect(marks.tags).toEqual([{ tag: "sandbox", group: "other", line: undefined }]); + expect(marks.grants).toEqual(new Map([["GM_audio", undefined]])); + }); + + it("标记顺序跟随代码出现顺序,便于与代码对读", () => { + const { code, metadata } = build(`// @name X\n// @top-level-await\n// @sandbox raw\n`); + expect(deriveCompatMarks(metadata, code).tags.map((t) => t.tag)).toEqual(["top-level-await", "sandbox"]); + }); +}); diff --git a/src/pages/install/compat.ts b/src/pages/install/compat.ts new file mode 100644 index 000000000..f6f29c7b5 --- /dev/null +++ b/src/pages/install/compat.ts @@ -0,0 +1,63 @@ +import type { SCMetadata } from "@App/app/repo/metadata"; +import { parseMetadataLines } from "@App/pkg/utils/script"; +import { isSupportedGrant, isSupportedMetadataTag, resolveMetadataTagBase } from "@App/pkg/utils/script_compat"; + +/** 不生效的指令挂到权限卡的哪一行呈现 */ +export type IneffectiveTagGroup = "match" | "other"; + +export interface IneffectiveTag { + /** 小写归一后的指令名,不含 @ */ + tag: string; + group: IneffectiveTagGroup; + line: number | undefined; +} + +export interface CompatMarks { + /** 不生效的 @grant → 所在行号;键与权限卡 GM 能力行的 chip 取值一致,直接按名字打标 */ + grants: Map; + /** 不生效的元数据指令;权限卡里没有对应 chip,按 group 追加呈现 */ + tags: IneffectiveTag[]; +} + +// 不生效的指令归到「它本该影响什么」那一行,读者才能就地判断后果;归不到的落在「其他指令」。 +const TAG_GROUP: Readonly> = { + "exclude-match": "match", + matchaboutblank: "match", +}; + +/** + * 派生安装页的兼容性标记:脚本写了、但脚本猫不会执行的指令与 GM 能力。 + * 判定是二元的(见 script_compat.ts),这里只负责定位与归组,不再分兼容程度。 + */ +export function deriveCompatMarks(metadata: SCMetadata, code: string): CompatMarks { + const lines = parseMetadataLines(code); + const firstLineOf = new Map(); + for (const { tag, value, line } of lines) { + const tagKey = `@${tag}`; + if (!firstLineOf.has(tagKey)) firstLineOf.set(tagKey, line); + // @grant 按取值定位,同一指令的不同能力各自成行 + if (tag === "grant") { + const grantKey = `grant:${value}`; + if (!firstLineOf.has(grantKey)) firstLineOf.set(grantKey, line); + } + } + + const grants = new Map(); + for (const grant of metadata.grant || []) { + if (grant === "none" || isSupportedGrant(grant) || grants.has(grant)) continue; + grants.set(grant, firstLineOf.get(`grant:${grant}`)); + } + + const seen = new Set(); + const tags: IneffectiveTag[] = []; + // 以代码出现顺序为准;metadata 是对象,键序不表达脚本里的书写顺序 + const ordered = [...lines.map((l) => l.tag), ...Object.keys(metadata)]; + for (const rawTag of ordered) { + const tag = resolveMetadataTagBase(rawTag); + if (seen.has(tag) || isSupportedMetadataTag(tag)) continue; + seen.add(tag); + tags.push({ tag, group: TAG_GROUP[tag] ?? "other", line: firstLineOf.get(`@${tag}`) }); + } + + return { grants, tags }; +} diff --git a/src/pkg/utils/monaco-editor/index.ts b/src/pkg/utils/monaco-editor/index.ts index 90ce5a151..d3d2a5b25 100644 --- a/src/pkg/utils/monaco-editor/index.ts +++ b/src/pkg/utils/monaco-editor/index.ts @@ -13,11 +13,11 @@ import { getUndefinedMetadataTagMatches, isMetadataAlignmentBlockAligned, metadataHoverPattern, - resolveMetadataTagBase, type MetadataAlignmentBlock, type MetadataAlignmentLine, type MetadataBlockRange, } from "./metadata"; +import { resolveMetadataTagBase, SUPPORTED_METADATA_TAGS } from "@App/pkg/utils/script_compat"; interface ILinterWorker extends Worker { myLinterHook: EventEmitter; @@ -59,14 +59,12 @@ const configuredLanguagePromise = systemConfig.getLanguage(); let currentEditorLang: EditorLangEntry; type EditorLangEntryPrompt = typeof currentEditorLang.prompt; let promptByMetadataTag: EditorLangEntryPrompt; -let knownMetadataTagSet: ReadonlySet; const loadEditorLangEntry = (languageCode: EditorLangCode) => { currentEditorLang = asEditorLangEntry(languageCode); promptByMetadataTag = Object.fromEntries( Object.entries(currentEditorLang.prompt).map(([metadataTag, prompt]) => [metadataTag.toLowerCase(), prompt]) ) as typeof currentEditorLang.prompt; - knownMetadataTagSet = new Set(Object.keys(promptByMetadataTag)); }; loadEditorLangEntry("en-US"); @@ -660,7 +658,7 @@ const getUndefinedMetadataTagMarkers = ( model: editor.ITextModel, blocks: MetadataAlignmentBlock[] ): editor.IMarkerData[] => - getUndefinedMetadataTagMatches(model, blocks, knownMetadataTagSet).map((match) => ({ + getUndefinedMetadataTagMatches(model, blocks, SUPPORTED_METADATA_TAGS).map((match) => ({ severity: MarkerSeverity.Warning, message: currentEditorLang.undefinedPrompt, source: scriptcatMarkerOwner, diff --git a/src/pkg/utils/monaco-editor/metadata.test.ts b/src/pkg/utils/monaco-editor/metadata.test.ts index 0d49b13d2..704706819 100644 --- a/src/pkg/utils/monaco-editor/metadata.test.ts +++ b/src/pkg/utils/monaco-editor/metadata.test.ts @@ -5,7 +5,6 @@ import { getMetadataAlignmentBlocks, getUndefinedMetadataTagMatches, isKnownMetadataTag, - resolveMetadataTagBase, } from "./metadata"; // 与 utils.test.ts 相同风格的简单 Monaco Editor 模型 mock @@ -60,17 +59,7 @@ describe("getMetadataAlignmentBlocks", () => { }); }); -describe("resolveMetadataTagBase / isKnownMetadataTag", () => { - it("本地化标签(name:)应解析为对应的基础标签", () => { - expect(resolveMetadataTagBase("name:zh-CN")).toBe("name"); - expect(resolveMetadataTagBase("description:en")).toBe("description"); - }); - - it("非本地化标签保持原样(小写化)", () => { - expect(resolveMetadataTagBase("Grant")).toBe("grant"); - expect(resolveMetadataTagBase("run-at")).toBe("run-at"); - }); - +describe("isKnownMetadataTag", () => { it("已知的本地化标签应视为已定义", () => { expect(isKnownMetadataTag("name:zh-CN", knownTags)).toBe(true); expect(isKnownMetadataTag("description:ja", knownTags)).toBe(true); diff --git a/src/pkg/utils/monaco-editor/metadata.ts b/src/pkg/utils/monaco-editor/metadata.ts index 0a248232e..a81621e8a 100644 --- a/src/pkg/utils/monaco-editor/metadata.ts +++ b/src/pkg/utils/monaco-editor/metadata.ts @@ -1,4 +1,5 @@ import type { editor } from "monaco-editor"; +import { resolveMetadataTagBase } from "@App/pkg/utils/script_compat"; export type MetadataAlignmentLine = { lineNumber: number; @@ -32,9 +33,6 @@ export const metadataLineStartPattern = /^\s*\/\/[ \t]*@/; export const userscriptHeaderPattern = /^\s*\/\/[ \t]*==UserScript==[ \t]*$/; export const userscriptEndPattern = /^\s*\/\/[ \t]*==\/UserScript==[ \t]*$/; const metadataAlignmentPattern = /^(\s*\/\/[ \t]*@)(\S+)([ \t]+)(.*)$/; -// ScriptCat 运行时消费 `name:`/`description:` (src/locales/locales.ts) 等本地化标签, -// 它们是合法标签而非拼写错误,需与其余标签区分开来单独判断。 -const localeSuffixedMetadataTagPattern = /^(name|description):(.+)$/; export const getMetadataAlignmentLine = (lineNumber: number, lineText: string): MetadataAlignmentLine | null => { const match = metadataAlignmentPattern.exec(lineText); @@ -94,12 +92,6 @@ export const isMetadataAlignmentBlockAligned = (block: MetadataAlignmentBlock) = return block.lines.every((line) => line.valueColumn === firstValueColumn); }; -export const resolveMetadataTagBase = (tag: string): string => { - const normalizedTag = tag.toLowerCase(); - const localeMatch = localeSuffixedMetadataTagPattern.exec(normalizedTag); - return localeMatch ? localeMatch[1] : normalizedTag; -}; - export const isKnownMetadataTag = (tag: string, knownTags: ReadonlySet): boolean => knownTags.has(resolveMetadataTagBase(tag)); diff --git a/src/pkg/utils/script.test.ts b/src/pkg/utils/script.test.ts index 2c1cf044b..48acb1c1e 100644 --- a/src/pkg/utils/script.test.ts +++ b/src/pkg/utils/script.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; -import { parseMetadata, parseScriptFromCode, fetchScriptBody, prepareScriptByCode } from "./script"; +import { parseMetadata, parseMetadataLines, parseScriptFromCode, fetchScriptBody, prepareScriptByCode } from "./script"; import { getMetadataStr, getUserConfigStr } from "./utils"; import { parseUserConfig } from "./yaml"; import { @@ -724,6 +724,65 @@ console.log('Hello World'); }); }); +describe.concurrent("parseMetadataLines", () => { + const code = `// 开头的普通注释 +// ==UserScript== +// @name 示例 +// @namespace https://example.com +// @match *://example.com/* +// @exclude-match *://live.example.com/* +// @grant GM_setValue +// ==/UserScript== + +console.log(1); +`; + + it("逐条给出指令名、取值与 1 起算的全文行号", () => { + expect(parseMetadataLines(code)).toEqual([ + { tag: "name", value: "示例", line: 3 }, + { tag: "namespace", value: "https://example.com", line: 4 }, + { tag: "match", value: "*://example.com/*", line: 5 }, + { tag: "exclude-match", value: "*://live.example.com/*", line: 6 }, + { tag: "grant", value: "GM_setValue", line: 7 }, + ]); + }); + + it("指令名小写归一,与 parseMetadata 的取键一致", () => { + const lines = parseMetadataLines(`// ==UserScript== +// @Name X +// @MATCH *://a.com/* +// ==/UserScript==`); + expect(lines.map((l) => l.tag)).toEqual(["name", "match"]); + }); + + it("同名指令重复出现时逐条保留,不合并", () => { + const lines = parseMetadataLines(`// ==UserScript== +// @name X +// @match *://a.com/* +// @match *://b.com/* +// ==/UserScript==`); + expect(lines.filter((l) => l.tag === "match").map((l) => l.line)).toEqual([3, 4]); + }); + + it("没有元数据区块时返回空列表", () => { + expect(parseMetadataLines("console.log(1);")).toEqual([]); + }); + + it("只认第一个闭合区块——与 parseMetadata 的 HEADER_BLOCK 语义一致", () => { + const lines = parseMetadataLines(`// ==UserScript== +// @name X +// ==/UserScript== +// ==UserScript== +// @name Y +// ==/UserScript==`); + expect(lines).toEqual([{ tag: "name", value: "X", line: 2 }]); + }); + + it("区块未闭合时不产出任何指令——与 parseMetadata 一致", () => { + expect(parseMetadataLines(`// ==UserScript==\n// @name X\n`)).toEqual([]); + }); +}); + describe.concurrent("getMetadataStr", () => { it.concurrent("提取UserScript元数据字符串", () => { const code = ` diff --git a/src/pkg/utils/script.ts b/src/pkg/utils/script.ts index 8473cedeb..8af95fe9f 100644 --- a/src/pkg/utils/script.ts +++ b/src/pkg/utils/script.ts @@ -21,6 +21,46 @@ import { readRawContent } from "@App/pkg/utils/encoding"; const HEADER_BLOCK = /\/\/[ \t]*==User(Script|Subscribe)==([\s\S]+?)\/\/[ \t]*==\/User\1==/m; const META_LINE = /\/\/[ \t]*@(\S+)[ \t]*(.*)$/gm; +export interface MetadataLine { + /** 小写归一后的指令名,与 parseMetadata 的取键一致 */ + tag: string; + value: string; + /** 1 起算的全文行号 */ + line: number; +} + +const HEADER_OPEN = /^\/\/[ \t]*==User(?:Script|Subscribe)==/; + +/** + * 带位置的元数据解析:与 parseMetadata 共用 HEADER_BLOCK / META_LINE, + * 保证诊断看到的指令集合与运行时实际解析出的完全一致(否则会对着一条运行时根本没读到的行报警)。 + * parseMetadata 按指令名聚合取值、丢弃位置,这里逐条保留顺序与行号。 + */ +export function parseMetadataLines(code: string): MetadataLine[] { + const block = HEADER_BLOCK.exec(code); + if (!block) return []; + const headerContent = block[2]; + const open = HEADER_OPEN.exec(block[0]); + if (!open) return []; + const headerStart = block.index + open[0].length; + + const lines: MetadataLine[] = []; + // META_LINE 的匹配按位置递增,逐段累计换行数即可,无需为每条指令从头数 + let scanned = 0; + let line = 1; + let m: RegExpExecArray | null; + META_LINE.lastIndex = 0; + while ((m = META_LINE.exec(headerContent)) !== null) { + const absolute = headerStart + m.index; + for (let i = scanned; i < absolute; i++) { + if (code.charCodeAt(i) === 10) line += 1; + } + scanned = absolute; + lines.push({ tag: m[1].toLowerCase(), value: m[2]?.trim() ?? "", line }); + } + return lines; +} + // 从脚本代码抽出Metadata export function parseMetadata(code: string): SCMetadata | null { let isSubscribe = false; diff --git a/src/pkg/utils/script_compat.test.ts b/src/pkg/utils/script_compat.test.ts new file mode 100644 index 000000000..cef91decb --- /dev/null +++ b/src/pkg/utils/script_compat.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect } from "vitest"; +import "@App/app/service/content/gm_api/gm_api"; +import { GMContextApiNames } from "@App/app/service/content/gm_api/gm_context"; +import { compatMap as eslintHeaderCompatMap } from "@Packages/eslint/compat-headers"; +import { + CONTEXT_PROVIDED_GRANTS, + SUPPORTED_GRANTS, + SUPPORTED_METADATA_TAGS, + SYNTHETIC_METADATA_TAGS, + isSupportedGrant, + isSupportedMetadataTag, + resolveMetadataTagBase, +} from "./script_compat"; + +describe("元数据指令支持判定", () => { + it("运行时会消费的指令视为支持", () => { + for (const tag of ["match", "include", "exclude", "grant", "run-at", "run-in", "noframes", "connect"]) { + expect(isSupportedMetadataTag(tag), tag).toBe(true); + } + }); + + it("脚本猫独有的指令视为支持", () => { + for (const tag of ["crontab", "background", "early-start", "require-css", "storagename", "cloudcat"]) { + expect(isSupportedMetadataTag(tag), tag).toBe(true); + } + }); + + it("别家管理器会执行、脚本猫未实现的指令视为不支持", () => { + for (const tag of ["exclude-match", "top-level-await", "sandbox", "webRequest", "allFrames", "user-agent"]) { + expect(isSupportedMetadataTag(tag), tag).toBe(false); + } + }); + + it("脚本站与著作信息类指令不报不支持——脚本猫不执行它们,但也不影响脚本行为", () => { + for (const tag of ["license", "compatible", "contributionURL", "uso:script", "oujs:author", "screenshot"]) { + expect(isSupportedMetadataTag(tag), tag).toBe(true); + } + }); + + it("指令名大小写不敏感", () => { + expect(isSupportedMetadataTag("MATCH")).toBe(true); + expect(isSupportedMetadataTag("Run-At")).toBe(true); + expect(isSupportedMetadataTag("EXCLUDE-MATCH")).toBe(false); + }); + + it("name/description 的语言后缀归到同一指令", () => { + expect(isSupportedMetadataTag("name:zh-CN")).toBe(true); + expect(isSupportedMetadataTag("description:ja")).toBe(true); + expect(resolveMetadataTagBase("Name:zh-CN")).toBe("name"); + // 只有 name/description 有语言后缀,其余指令的冒号后缀不参与归一 + expect(resolveMetadataTagBase("uso:script")).toBe("uso:script"); + }); + + it("拼写错误等未知指令视为不支持", () => { + expect(isSupportedMetadataTag("mathc")).toBe(false); + expect(isSupportedMetadataTag("matchAboutBlank")).toBe(false); + }); +}); + +describe("GM 能力支持判定", () => { + it("注册表中的 GM API 视为支持", () => { + for (const grant of ["GM_setValue", "GM.setValue", "CAT_fileStorage", "CAT.agent.dom", "window.close"]) { + expect(isSupportedGrant(grant), grant).toBe(true); + } + }); + + it("GM_ 与 GM. 前缀互认——与运行时 getGrantCandidates 同一套规则", () => { + // 注册表只登记了 GM_xmlhttpRequest / GM.xmlHttpRequest 两种写法,交叉写法靠候选规则兜住 + expect(isSupportedGrant("GM.deleteValues")).toBe(true); + expect(isSupportedGrant("GM_deleteValues")).toBe(true); + }); + + it("不经注册表、由上下文直接提供的能力也视为支持", () => { + for (const grant of ["unsafeWindow", "window.onurlchange", "GM_info", "none"]) { + expect(isSupportedGrant(grant), grant).toBe(true); + } + }); + + it("脚本猫未实现的 GM API 视为不支持", () => { + for (const grant of ["GM_audio", "GM_webRequest", "GM_addScript", "GM_createObjectURL"]) { + expect(isSupportedGrant(grant), grant).toBe(false); + } + }); +}); + +describe("支持表与运行时注册表的一致性", () => { + it("注册表里的每个 @grant 都在支持表内——新增 GM API 漏进表会在此转红", () => { + const missing = GMContextApiNames().filter((name) => !SUPPORTED_GRANTS.has(name)); + expect(missing).toEqual([]); + }); + + it("支持表不含注册表之外的名字——上下文直接提供的能力单独登记,便于审阅", () => { + const registered = new Set(GMContextApiNames()); + const extra = [...SUPPORTED_GRANTS].filter((name) => !registered.has(name) && !CONTEXT_PROVIDED_GRANTS.has(name)); + expect(extra).toEqual([]); + }); +}); + +describe("支持表与编辑器 ESLint 合法 header 集合的一致性", () => { + it("支持表里的指令,编辑器都应认为是合法 header——否则用户会在编辑器里收到「不是合法 header」的误报", () => { + // no-invalid-headers 按原样比对 header 名,而脚本猫运行时把指令名小写化,故按小写比对 + const validHeaders = new Set( + [ + ...Object.keys(eslintHeaderCompatMap.unlocalized), + ...Object.keys(eslintHeaderCompatMap.nonFunctional), + ...Object.keys(eslintHeaderCompatMap.localized), + ].map((key) => key.toLowerCase()) + ); + const missing = [...SUPPORTED_METADATA_TAGS].filter( + (tag) => !SYNTHETIC_METADATA_TAGS.has(tag) && !validHeaders.has(tag) + ); + expect(missing).toEqual([]); + }); +}); diff --git a/src/pkg/utils/script_compat.ts b/src/pkg/utils/script_compat.ts new file mode 100644 index 000000000..2a8ec950b --- /dev/null +++ b/src/pkg/utils/script_compat.ts @@ -0,0 +1,208 @@ +import { getGrantCandidates } from "@App/app/service/content/gm_api/grant"; + +/** + * 脚本猫的兼容性支持表:安装页与编辑器共用的唯一判定来源。 + * + * 判定是二元的——指令/能力要么被脚本猫消费,要么写了也不会生效,没有中间档。 + * 表外即「不生效」,因此收录标准是「脚本猫会消费它」或「脚本猫不消费但它也不改变脚本运行行为」; + * 只有会改变别家管理器下脚本行为、而脚本猫没实现的指令才刻意留在表外(如 @exclude-match)。 + */ + +// name/description/antifeature 可带 `:` 后缀取本地化值(src/locales/locales.ts), +// 是同一条指令的语言变体而非独立指令。其余指令的冒号(uso:script 等)是名字的一部分。 +const LOCALE_SUFFIXED_TAG = /^(name|description|antifeature):(.+)$/; + +/** 归一到判定用的指令名:小写,并去掉本地化后缀 */ +export const resolveMetadataTagBase = (tag: string): string => { + const normalized = tag.toLowerCase(); + const localeMatch = LOCALE_SUFFIXED_TAG.exec(normalized); + return localeMatch ? localeMatch[1] : normalized; +}; + +// 脚本猫会读取的指令:注入与匹配链路、脚本类型判定、云端/订阅、以及列表与安装页的展示字段。 +const CONSUMED_TAGS = [ + "name", + "namespace", + "version", + "description", + "author", + "match", + "include", + "exclude", + "connect", + "grant", + "require", + "require-css", + "resource", + "run-at", + "run-in", + "noframes", + "inject-into", + "unwrap", + "early-start", + "background", + "crontab", + "antifeature", + "tag", + "storagename", + "cloudcat", + "cloudserver", + "exportvalue", + "exportcookie", + "scripturl", + "usersubscribe", + "updateurl", + "downloadurl", + "icon", + "iconurl", + "icon64", + "icon64url", + "defaulticon", +]; + +// 脚本猫不消费,但也不改变脚本运行行为的指令:著作信息、脚本站元数据、构建提示。 +// 它们出现在脚本里是正常的,标成「不生效」只会制造噪音。 +// definition 是脚本猫自己文档化的编辑器指令,当前没有任何消费方;在它被实现或从文档撤下之前 +// 按信息类处理,避免安装页对着脚本猫自己的文档报警。 +const INFORMATIONAL_TAGS = [ + "license", + "copyright", + "homepage", + "homepageurl", + "website", + "source", + "supporturl", + "installurl", + "compatible", + "definition", + "contributor", + "contributors", + "collaborator", + "creator", + "developer", + "contributionurl", + "contributionamount", + "screenshot", + "history", + "id", + "major", + "minor", + "build", + "unstableminify", + "oujs:author", + "oujs:collaborator", + "uso:script", + "uso:version", + "uso:timestamp", + "uso:hash", + "uso:rating", + "uso:installs", + "uso:reviews", + "uso:discussions", + "uso:fans", + "uso:unlisted", +]; + +// 不是作者写出来的指令,而是 parseMetadata 为 .user.sub.js 合成的标记; +// 订阅脚本的 metadata 里会出现这个键,必须视为支持,否则安装页会把它标成不生效。 +export const SYNTHETIC_METADATA_TAGS: ReadonlySet = new Set(["usersubscribe"]); + +export const SUPPORTED_METADATA_TAGS: ReadonlySet = new Set([...CONSUMED_TAGS, ...INFORMATIONAL_TAGS]); + +export const isSupportedMetadataTag = (tag: string): boolean => + SUPPORTED_METADATA_TAGS.has(resolveMetadataTagBase(tag)); + +// 不经 GMContext 注册表、由沙盒上下文直接提供或无需授权的能力: +// unsafeWindow 与 GM_info 恒定注入(src/app/service/content/create_context.ts、exec_script.ts), +// window.onurlchange 在 createContext 里单独接管,none 表示不请求任何 GM 能力。 +export const CONTEXT_PROVIDED_GRANTS: ReadonlySet = new Set([ + "none", + "unsafeWindow", + "GM_info", + "GM.info", + "window.onurlchange", +]); + +// GMContext 注册表在 src/app/service/content/gm_api/ 由装饰器填充,安装页不能为了查一次支持性 +// 把整套 GM 实现拉进包里,因此在此静态镜像一份;script_compat.test.ts 守卫两者一致。 +const REGISTERED_GRANTS = [ + "CAT.agent.conversation", + "CAT.agent.dom", + "CAT.agent.model", + "CAT.agent.opfs", + "CAT.agent.skills", + "CAT.agent.task", + "CAT_createBlobUrl", + "CAT_fetchBlob", + "CAT_fetchDocument", + "CAT_fileStorage", + "CAT_registerMenuInput", + "CAT_scriptLoaded", + "CAT_unregisterMenuInput", + "CAT_userConfig", + "GM.addElement", + "GM.addStyle", + "GM.addValueChangeListener", + "GM.closeInTab", + "GM.closeNotification", + "GM.cookie", + "GM.deleteValue", + "GM.deleteValues", + "GM.download", + "GM.getResourceText", + "GM.getResourceURL", + "GM.getResourceUrl", + "GM.getTab", + "GM.getTabs", + "GM.getValue", + "GM.getValues", + "GM.listValues", + "GM.log", + "GM.notification", + "GM.openInTab", + "GM.registerMenuCommand", + "GM.removeValueChangeListener", + "GM.saveTab", + "GM.setClipboard", + "GM.setValue", + "GM.setValues", + "GM.unregisterMenuCommand", + "GM.updateNotification", + "GM.xmlHttpRequest", + "GM_addElement", + "GM_addStyle", + "GM_addValueChangeListener", + "GM_closeInTab", + "GM_closeNotification", + "GM_cookie", + "GM_deleteValue", + "GM_deleteValues", + "GM_download", + "GM_getResourceText", + "GM_getResourceURL", + "GM_getTab", + "GM_getTabs", + "GM_getValue", + "GM_getValues", + "GM_listValues", + "GM_log", + "GM_notification", + "GM_openInTab", + "GM_registerMenuCommand", + "GM_removeValueChangeListener", + "GM_saveTab", + "GM_setClipboard", + "GM_setValue", + "GM_setValues", + "GM_unregisterMenuCommand", + "GM_updateNotification", + "GM_xmlhttpRequest", + "window.close", + "window.focus", +]; + +export const SUPPORTED_GRANTS: ReadonlySet = new Set([...REGISTERED_GRANTS, ...CONTEXT_PROVIDED_GRANTS]); + +// 与运行时同一套候选规则:@grant GM.foo 与 GM_foo 互认(src/app/service/content/gm_api/grant.ts) +export const isSupportedGrant = (grant: string): boolean => + getGrantCandidates(grant).some((candidate) => SUPPORTED_GRANTS.has(candidate)); From c65029f9ec2ce167904815098ccdfe6fa1a74ddd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Mon, 14 Sep 2026 14:23:36 +0800 Subject: [PATCH 2/9] =?UTF-8?q?=E2=9C=A8=20=E5=AE=89=E8=A3=85=E9=A1=B5?= =?UTF-8?q?=E6=A0=87=E5=87=BA=E4=B8=8D=E7=94=9F=E6=95=88=E7=9A=84=E6=8C=87?= =?UTF-8?q?=E4=BB=A4=E4=B8=8E=20GM=20=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户安装前看不出脚本里哪些声明脚本猫不会执行。以 @exclude-match 为例:脚本猫 只识别 @match/@include/@exclude,这条声明被原样保留却从不参与匹配,本该被排除 的页面照样会运行脚本,而安装页对此只字不提,装完也不会有任何提示(#1713)。 判定沿用支持表,是二元的:脚本猫不消费就是不生效,不再分兼容程度。标记就近长在 它所属的权限行上,不另起一张卡——不受支持的 @grant 直接替换权限行里原有的 chip, 匹配类声明(@exclude-match)追加到「运行网站」行,归不到任何权限类别的(@sandbox、 @top-level-await 等)落在新增的「其他声明」行。说明走浮层,鼠标移入与键盘聚焦都能 打开;点击 chip 本身恒为跳转,展开代码卡并滚动到该声明所在行并选中。含义不只靠颜色 传达:每枚 chip 带禁止图标与「不生效」的无障碍名。 两处折叠形态会藏掉标记,一并处理:权限一项没变时整卡本会塌成一行,有不生效项时 不塌(折叠的是上次已确认过的权限,而标记是这次才出现的新信息);移动端有标记的 类别默认展开。全都没有时安装页与今天一字不改。 代码定位要求 diff 预览也能拿到实例,为此 CodeEditor 新增 onReady(普通与 diff 两条 分支都触发),revealLine 在 diff 预览里定位到修改侧。这连带修掉一个既有缺陷:此前 onEditorMount 只在普通分支触发,更新安装页(有 diff)的代码骨架永远收不起来——真实 浏览器确认过它被 monaco 盖住不影响观感,但 role=status + aria-busy 会一直留在无障碍 树里说「正在加载代码」。tests/mocks/CodeEditor.tsx 此前无视 diff 一律回调,正是它让 这个缺陷在单元测试里看不出来,现已与真实实现对齐。 --- src/locales/de-DE/install.json | 10 +- src/locales/en-US/install.json | 10 +- src/locales/ja-JP/install.json | 10 +- src/locales/ko-KR/install.json | 10 +- src/locales/pt-BR/install.json | 10 +- src/locales/ru-RU/install.json | 10 +- src/locales/tr-TR/install.json | 10 +- src/locales/vi-VN/install.json | 10 +- src/locales/zh-CN/install.json | 10 +- src/locales/zh-TW/install.json | 10 +- .../components/CodeEditor/index.test.tsx | 53 ++++++++++- src/pages/components/CodeEditor/index.tsx | 31 ++++++- src/pages/install/App.test.tsx | 32 +++++++ src/pages/install/App.tsx | 14 ++- src/pages/install/compat.ts | 14 +++ .../install/components/CodePreview.test.tsx | 39 +++++++- src/pages/install/components/CodePreview.tsx | 43 ++++++++- .../install/components/CompatChip.test.tsx | 77 ++++++++++++++++ src/pages/install/components/CompatChip.tsx | 92 +++++++++++++++++++ .../components/PermissionCard.test.tsx | 75 +++++++++++++++ .../install/components/PermissionCard.tsx | 75 ++++++++++++--- .../install/components/PermissionRow.test.tsx | 57 ++++++++++++ .../install/components/PermissionRow.tsx | 49 +++++++--- src/pages/install/useInstallData.test.ts | 18 ++++ src/pages/install/useInstallData.ts | 4 + tests/mocks/CodeEditor.tsx | 21 ++++- 26 files changed, 741 insertions(+), 53 deletions(-) create mode 100644 src/pages/install/components/CompatChip.test.tsx create mode 100644 src/pages/install/components/CompatChip.tsx diff --git a/src/locales/de-DE/install.json b/src/locales/de-DE/install.json index f22259290..045519f21 100644 --- a/src/locales/de-DE/install.json +++ b/src/locales/de-DE/install.json @@ -254,5 +254,13 @@ "expired_title": "Update-Inhalt ist abgelaufen", "expired_desc": "Der für dieses Update vorbereitete Code wurde bereinigt. Bitte erneut nach Updates suchen.", "expired_recheck": "Erneut nach Updates suchen", - "code_loading": "Code wird geladen" + "code_loading": "Code wird geladen", + "compat_ineffective": "ohne Wirkung", + "compat_tag_desc": "ScriptCat unterstützt diese Deklaration nicht; sie wird nach der Installation ignoriert.", + "compat_grant_desc": "ScriptCat implementiert diese API nicht; Aufrufe schlagen fehl und darauf aufbauende Funktionen arbeiten nicht.", + "compat_jump": "Zu Zeile {{line}} springen", + "compat_docs": "Kompatibilitätsdokumentation", + "compat_count": "{{count}} ohne Wirkung", + "perm_other_label": "Weitere Deklarationen", + "perm_other_summary": "Vom Skript deklariert, wird von ScriptCat aber nicht ausgeführt" } diff --git a/src/locales/en-US/install.json b/src/locales/en-US/install.json index 64c24de4f..6949c896f 100644 --- a/src/locales/en-US/install.json +++ b/src/locales/en-US/install.json @@ -254,5 +254,13 @@ "expired_title": "Update content has expired", "expired_desc": "The code prepared for this update has been cleaned up. Please check for updates again.", "expired_recheck": "Check for updates again", - "code_loading": "Loading code" + "code_loading": "Loading code", + "compat_ineffective": "no effect", + "compat_tag_desc": "ScriptCat does not support this declaration; it is ignored after installation.", + "compat_grant_desc": "ScriptCat has not implemented this API; calls to it fail and features relying on it won't work.", + "compat_jump": "Go to line {{line}}", + "compat_docs": "Compatibility docs", + "compat_count": "{{count}} with no effect", + "perm_other_label": "Other declarations", + "perm_other_summary": "Declared by the script but not executed by ScriptCat" } diff --git a/src/locales/ja-JP/install.json b/src/locales/ja-JP/install.json index 0abd1017c..81b48a379 100644 --- a/src/locales/ja-JP/install.json +++ b/src/locales/ja-JP/install.json @@ -254,5 +254,13 @@ "expired_title": "更新内容の有効期限が切れました", "expired_desc": "この更新のために用意されたコードは削除されました。もう一度更新を確認してください。", "expired_recheck": "更新を再確認", - "code_loading": "コードを読み込み中" + "code_loading": "コードを読み込み中", + "compat_ineffective": "無効", + "compat_tag_desc": "ScriptCat はこの宣言に対応していないため、インストール後は無視されます。", + "compat_grant_desc": "ScriptCat はこの API を実装していないため、スクリプトから呼び出すとエラーになり、これに依存する機能は動作しません。", + "compat_jump": "{{line}} 行目へ移動", + "compat_docs": "互換性ドキュメント", + "compat_count": "無効な項目 {{count}} 件", + "perm_other_label": "その他の宣言", + "perm_other_summary": "宣言されていますが ScriptCat では実行されません" } diff --git a/src/locales/ko-KR/install.json b/src/locales/ko-KR/install.json index a06879c34..d99b8e78f 100644 --- a/src/locales/ko-KR/install.json +++ b/src/locales/ko-KR/install.json @@ -254,5 +254,13 @@ "expired_title": "업데이트 내용이 만료되었습니다", "expired_desc": "이번 업데이트를 위해 준비한 코드가 정리되었습니다. 업데이트를 다시 확인해 주세요.", "expired_recheck": "업데이트 다시 확인", - "code_loading": "코드를 불러오는 중" + "code_loading": "코드를 불러오는 중", + "compat_ineffective": "적용되지 않음", + "compat_tag_desc": "ScriptCat은 이 선언을 지원하지 않으므로 설치 후 무시됩니다.", + "compat_grant_desc": "ScriptCat은 이 API를 구현하지 않았습니다. 스크립트에서 호출하면 오류가 발생하고 이에 의존하는 기능은 동작하지 않습니다.", + "compat_jump": "{{line}}번째 줄로 이동", + "compat_docs": "호환성 문서", + "compat_count": "적용되지 않는 항목 {{count}}개", + "perm_other_label": "기타 선언", + "perm_other_summary": "선언되었지만 ScriptCat이 실행하지 않습니다" } diff --git a/src/locales/pt-BR/install.json b/src/locales/pt-BR/install.json index 93047d702..0990111b3 100644 --- a/src/locales/pt-BR/install.json +++ b/src/locales/pt-BR/install.json @@ -254,5 +254,13 @@ "expired_title": "O conteúdo da atualização expirou", "expired_desc": "O código preparado para esta atualização foi removido. Verifique as atualizações novamente.", "expired_recheck": "Verificar atualizações novamente", - "code_loading": "Carregando código" + "code_loading": "Carregando código", + "compat_ineffective": "sem efeito", + "compat_tag_desc": "O ScriptCat não oferece suporte a esta declaração; ela é ignorada após a instalação.", + "compat_grant_desc": "O ScriptCat não implementou esta API; as chamadas falham e os recursos que dependem dela não funcionam.", + "compat_jump": "Ir para a linha {{line}}", + "compat_docs": "Documentação de compatibilidade", + "compat_count": "{{count}} sem efeito", + "perm_other_label": "Outras declarações", + "perm_other_summary": "Declarado pelo script, mas não executado pelo ScriptCat" } diff --git a/src/locales/ru-RU/install.json b/src/locales/ru-RU/install.json index f452106b2..0798b3ac2 100644 --- a/src/locales/ru-RU/install.json +++ b/src/locales/ru-RU/install.json @@ -254,5 +254,13 @@ "expired_title": "Содержимое обновления устарело", "expired_desc": "Код, подготовленный для этого обновления, был очищен. Проверьте обновления ещё раз.", "expired_recheck": "Проверить обновления ещё раз", - "code_loading": "Загрузка кода" + "code_loading": "Загрузка кода", + "compat_ineffective": "не действует", + "compat_tag_desc": "ScriptCat не поддерживает это объявление — после установки оно игнорируется.", + "compat_grant_desc": "ScriptCat не реализует этот API: вызов из скрипта завершится ошибкой, а зависящие от него функции работать не будут.", + "compat_jump": "Перейти к строке {{line}}", + "compat_docs": "Документация по совместимости", + "compat_count": "Не действует: {{count}}", + "perm_other_label": "Прочие объявления", + "perm_other_summary": "Объявлено скриптом, но ScriptCat это не выполняет" } diff --git a/src/locales/tr-TR/install.json b/src/locales/tr-TR/install.json index 0cb0a6752..4cdc157a4 100644 --- a/src/locales/tr-TR/install.json +++ b/src/locales/tr-TR/install.json @@ -254,5 +254,13 @@ "expired_title": "Güncelleme içeriğinin süresi doldu", "expired_desc": "Bu güncelleme için hazırlanan kod temizlendi. Lütfen güncellemeleri yeniden denetleyin.", "expired_recheck": "Güncellemeleri yeniden denetle", - "code_loading": "Kod yükleniyor" + "code_loading": "Kod yükleniyor", + "compat_ineffective": "etkisiz", + "compat_tag_desc": "ScriptCat bu bildirimi desteklemiyor; kurulumdan sonra yok sayılır.", + "compat_grant_desc": "ScriptCat bu API'yi uygulamadı; betik çağırdığında hata verir ve buna dayanan özellikler çalışmaz.", + "compat_jump": "{{line}}. satıra git", + "compat_docs": "Uyumluluk belgeleri", + "compat_count": "{{count}} etkisiz", + "perm_other_label": "Diğer bildirimler", + "perm_other_summary": "Betikte bildirildi ancak ScriptCat tarafından çalıştırılmıyor" } diff --git a/src/locales/vi-VN/install.json b/src/locales/vi-VN/install.json index 6f99c54d7..20ff18f52 100644 --- a/src/locales/vi-VN/install.json +++ b/src/locales/vi-VN/install.json @@ -254,5 +254,13 @@ "expired_title": "Nội dung cập nhật đã hết hạn", "expired_desc": "Mã đã chuẩn bị cho lần cập nhật này đã bị dọn dẹp. Vui lòng kiểm tra cập nhật lại.", "expired_recheck": "Kiểm tra cập nhật lại", - "code_loading": "Đang tải mã" + "code_loading": "Đang tải mã", + "compat_ineffective": "không có tác dụng", + "compat_tag_desc": "ScriptCat không hỗ trợ khai báo này; nó sẽ bị bỏ qua sau khi cài đặt.", + "compat_grant_desc": "ScriptCat chưa triển khai API này; script gọi đến sẽ báo lỗi và các tính năng phụ thuộc vào nó sẽ không hoạt động.", + "compat_jump": "Đến dòng {{line}}", + "compat_docs": "Tài liệu tương thích", + "compat_count": "{{count}} mục không có tác dụng", + "perm_other_label": "Khai báo khác", + "perm_other_summary": "Script có khai báo nhưng ScriptCat không thực thi" } diff --git a/src/locales/zh-CN/install.json b/src/locales/zh-CN/install.json index 3947d3f7a..8afdd420e 100644 --- a/src/locales/zh-CN/install.json +++ b/src/locales/zh-CN/install.json @@ -254,5 +254,13 @@ "expired_title": "更新内容已过期", "expired_desc": "这次更新准备好的代码已被清理,需要重新检查一次更新。", "expired_recheck": "重新检查更新", - "code_loading": "正在加载代码" + "code_loading": "正在加载代码", + "compat_ineffective": "不生效", + "compat_tag_desc": "脚本猫不支持该声明,安装后会被忽略。", + "compat_grant_desc": "脚本猫未实现该 API,脚本调用时会报错,依赖它的功能不可用。", + "compat_jump": "跳到第 {{line}} 行", + "compat_docs": "兼容性文档", + "compat_count": "{{count}} 项不生效", + "perm_other_label": "其他声明", + "perm_other_summary": "已声明,但脚本猫不会执行" } diff --git a/src/locales/zh-TW/install.json b/src/locales/zh-TW/install.json index 6998fe976..1baf3ab38 100644 --- a/src/locales/zh-TW/install.json +++ b/src/locales/zh-TW/install.json @@ -254,5 +254,13 @@ "expired_title": "更新內容已過期", "expired_desc": "這次更新準備好的程式碼已被清理,需要重新檢查一次更新。", "expired_recheck": "重新檢查更新", - "code_loading": "正在載入程式碼" + "code_loading": "正在載入程式碼", + "compat_ineffective": "不生效", + "compat_tag_desc": "腳本貓不支援此宣告,安裝後會被忽略。", + "compat_grant_desc": "腳本貓尚未實作此 API,腳本呼叫時會出錯,依賴它的功能無法使用。", + "compat_jump": "跳至第 {{line}} 行", + "compat_docs": "相容性文件", + "compat_count": "{{count}} 項不生效", + "perm_other_label": "其他宣告", + "perm_other_summary": "已宣告,但腳本貓不會執行" } diff --git a/src/pages/components/CodeEditor/index.test.tsx b/src/pages/components/CodeEditor/index.test.tsx index 8661091b4..09e9b60e0 100644 --- a/src/pages/components/CodeEditor/index.test.tsx +++ b/src/pages/components/CodeEditor/index.test.tsx @@ -5,6 +5,8 @@ import { render, cleanup, act, waitFor } from "@testing-library/react"; // 用 hoisted 持有可在测试内变更的主题与 monaco 桩,供被提升的 vi.mock 工厂引用 const h = vi.hoisted(() => { const makeEditor = () => ({ + revealLineInCenter: vi.fn(), + setSelection: vi.fn(), setModel: vi.fn(), setValue: vi.fn(), updateOptions: vi.fn(), @@ -18,7 +20,10 @@ const h = vi.hoisted(() => { return { resolvedTheme: "light" as string, setTheme: vi.fn(), - createDiffEditor: vi.fn((_container?: unknown, _options?: any) => makeEditor()), + createDiffEditor: vi.fn((_container?: unknown, _options?: any) => { + const modified = makeEditor(); + return { ...makeEditor(), getModifiedEditor: () => modified, __modified: modified }; + }), create: vi.fn((_container?: unknown, _options?: any) => makeEditor()), createModel: vi.fn(() => ({ dispose: vi.fn(), @@ -36,7 +41,14 @@ vi.mock("monaco-editor", () => ({ setTheme: h.setTheme, setModelMarkers: vi.fn(), }, - Range: class {}, + Range: class { + constructor( + public startLineNumber: number, + public startColumn: number, + public endLineNumber: number, + public endColumn: number + ) {} + }, })); vi.mock("./theme", () => ({ resolveMonacoTheme: (t: string) => t })); vi.mock("@App/pkg/utils/monaco-editor", () => ({ @@ -126,3 +138,40 @@ describe("CodeEditor 可访问性与主题", () => { expect(h.setTheme).toHaveBeenCalledWith("dark"); }); }); + +describe("CodeEditor 就绪信号与定位", () => { + it("内联 diff 也报告就绪——否则加载占位会一直留在无障碍树里说「正在加载」", async () => { + const onReady = vi.fn(); + render(); + await waitFor(() => expect(onReady).toHaveBeenCalled()); + }); + + it("普通编辑器同样报告就绪", async () => { + const onReady = vi.fn(); + await act(async () => { + render(); + }); + await waitFor(() => expect(onReady).toHaveBeenCalled()); + }); + + it("revealLine 在普通编辑器上滚动并选中整行", async () => { + const ref = createRef>(); + await act(async () => { + render(); + }); + await waitFor(() => expect(h.create).toHaveBeenCalled()); + act(() => ref.current?.revealLine(2)); + const instance = h.create.mock.results[0].value; + expect(instance.revealLineInCenter).toHaveBeenCalledWith(2); + expect(instance.setSelection).toHaveBeenCalled(); + }); + + it("revealLine 在 diff 预览里定位到修改侧——诊断说的是新版本的那一行", async () => { + const ref = createRef>(); + render(); + await waitFor(() => expect(h.createDiffEditor).toHaveBeenCalled()); + act(() => ref.current?.revealLine(2)); + const modified = (h.createDiffEditor.mock.results[0].value as any).__modified; + expect(modified.revealLineInCenter).toHaveBeenCalledWith(2); + }); +}); diff --git a/src/pages/components/CodeEditor/index.tsx b/src/pages/components/CodeEditor/index.tsx index d0465e68a..621336ce3 100644 --- a/src/pages/components/CodeEditor/index.tsx +++ b/src/pages/components/CodeEditor/index.tsx @@ -7,15 +7,25 @@ import { clearModelEslintFixes, getModelEslintFixKey } from "@App/pkg/utils/mona import { useTheme } from "@App/pages/components/theme-provider"; import { resolveMonacoTheme } from "./theme"; +export interface CodeEditorHandle { + /** 普通编辑器实例;diff 预览没有可编辑实例,为 undefined */ + editor: editor.IStandaloneCodeEditor | undefined; + /** 滚动到指定行并选中整行;diff 预览定位到修改侧 */ + revealLine: (line: number) => void; +} + type Props = { - ref?: Ref<{ editor: editor.IStandaloneCodeEditor | undefined }>; + ref?: Ref; className?: string; diffCode?: string; // 代码加载是异步的:undefined=不确定(不加载),""=无 diff,有值=diff editable?: boolean; id: string; code?: string; onChange?: (code: string) => void; + /** 普通编辑器实例就绪;diff 预览没有可编辑实例,不会触发 */ onEditorMount?: (editor: editor.IStandaloneCodeEditor) => void; + /** 编辑器已创建(含 diff 预览);用于收起加载占位 */ + onReady?: () => void; }; type TMarker = { @@ -44,7 +54,7 @@ function toMonacoEditorPreferenceOptions( } satisfies editor.IEditorOptions; } -function CodeEditor({ id, className, code, diffCode, editable, onChange, onEditorMount, ref }: Props) { +function CodeEditor({ id, className, code, diffCode, editable, onChange, onEditorMount, onReady, ref }: Props) { const [monacoEditor, setEditor] = useState(); const editorInstanceRef = useRef(undefined); // 普通 editor 与 diff editor 都会置位,供主题切换 effect 判断实例是否就绪 @@ -58,14 +68,27 @@ function CodeEditor({ id, className, code, diffCode, editable, onChange, onEdito // 用 ref 保存最新回调,避免 stale closure 同时不让创建 effect 重跑 const onChangeRef = useRef(onChange); const onEditorMountRef = useRef(onEditorMount); + const onReadyRef = useRef(onReady); // ref 赋值须在创建 effect 之前,确保 mount 时创建 effect 同步读到最新 onEditorMount useEffect(() => { onChangeRef.current = onChange; onEditorMountRef.current = onEditorMount; + onReadyRef.current = onReady; }); const divRef = useRef(null); - useImperativeHandle(ref, () => ({ editor: monacoEditor })); + useImperativeHandle(ref, () => ({ + editor: monacoEditor, + revealLine: (line: number) => { + const instance = editorInstanceRef.current; + if (!instance) return; + // diff 预览的行号说的是新版本,定位到修改侧 + const target = "getModifiedEditor" in instance ? instance.getModifiedEditor() : instance; + target.revealLineInCenter(line); + const maxColumn = target.getModel()?.getLineMaxColumn(line) ?? 1; + target.setSelection(new Range(line, 1, line, maxColumn)); + }, + })); // 注册 monaco 全局环境(只需执行一次) useEffect(() => { @@ -180,6 +203,7 @@ function CodeEditor({ id, className, code, diffCode, editable, onChange, onEdito }); editorInstanceRef.current = edit; editorReadyRef.current = true; + onReadyRef.current?.(); } else { const standaloneEdit = editor.create(container, { language: "javascript", @@ -199,6 +223,7 @@ function CodeEditor({ id, className, code, diffCode, editable, onChange, onEdito editorInstanceRef.current = standaloneEdit; editorReadyRef.current = true; onEditorMountRef.current?.(standaloneEdit); + onReadyRef.current?.(); } }); diff --git a/src/pages/install/App.test.tsx b/src/pages/install/App.test.tsx index 48b874fd4..aa3413d04 100644 --- a/src/pages/install/App.test.tsx +++ b/src/pages/install/App.test.tsx @@ -7,6 +7,7 @@ vi.mock("./useInstallData", () => ({ useInstallData: vi.fn() })); // Monaco 编辑器无法在 DOM 测试环境中渲染(需 worker + ThemeProvider),用桩替换 vi.mock("@App/pages/components/CodeEditor", () => import("@Tests/mocks/CodeEditor.tsx")); +import { revealLine } from "@Tests/mocks/CodeEditor"; import { useInstallData, type InstallView } from "./useInstallData"; import App from "./App"; @@ -41,11 +42,13 @@ const readyView = (over: Partial = {}): InstallView => ({ schedule: null, code: "// a\n// b", subscribeScripts: [], + compat: { grants: new Map(), tags: [] }, ...over, }); beforeEach(() => { mockMatchMedia(); + revealLine.calls.length = 0; }); beforeAll(() => initTestLanguage("zh-CN")); @@ -386,3 +389,32 @@ describe("Install App 不再渲染安全警示条", () => { expect(screen.queryByTestId("install-warning-risk")).not.toBeInTheDocument(); }); }); + +describe("安装页的不生效标记", () => { + it("把不受支持的 GM 能力标在权限行上,点击跳到代码对应行", () => { + mockHook.mockReturnValue({ + ...baseHook(), + state: { + status: "ready", + view: readyView({ + permissions: [{ kind: "grant", risk: "warn", values: ["GM_setValue", "GM_audio"], sensitive: [] }], + code: "// ==UserScript==\n// @name X\n// @grant GM_audio\n// ==/UserScript==", + compat: { grants: new Map([["GM_audio", 3]]), tags: [] }, + }), + }, + }); + render(); + + const chip = screen.getByTestId("compat-chip"); + expect(chip).toHaveTextContent("GM_audio"); + fireEvent.click(chip); + expect(revealLine.calls).toEqual([3]); + }); + + it("没有不生效项时权限区一字不改", () => { + mockHook.mockReturnValue({ ...baseHook(), state: { status: "ready", view: readyView() } }); + render(); + expect(screen.queryByTestId("compat-chip")).not.toBeInTheDocument(); + expect(screen.queryByTestId("permission-row-other")).not.toBeInTheDocument(); + }); +}); diff --git a/src/pages/install/App.tsx b/src/pages/install/App.tsx index b3629ba45..9f446e384 100644 --- a/src/pages/install/App.tsx +++ b/src/pages/install/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Download, RefreshCw, Rss, HardDrive, RotateCcw, PlugZap } from "lucide-react"; import { useIsMobile } from "@App/pages/components/use-is-mobile"; @@ -8,7 +8,7 @@ import { ScriptIdentity } from "./components/ScriptIdentity"; import { PermissionCard } from "./components/PermissionCard"; import { SubscribeScripts } from "./components/SubscribeScripts"; import { SkillInstallView } from "./components/SkillInstallView"; -import { CodePreview } from "./components/CodePreview"; +import { CodePreview, type CodePreviewHandle } from "./components/CodePreview"; import { InstallActions } from "./components/InstallActions"; import { InstallLoading, InstallError, InstallExpired } from "./components/InstallStates"; import { WatchingBanner } from "./components/WatchingBanner"; @@ -52,6 +52,7 @@ export default function App() { retryInstall, } = useInstallData(); const [bgPrompt, setBgPrompt] = useState<{ scriptType: string; permission: PromptPermission } | null>(null); + const codePreviewRef = useRef(null); const installed = outcome.phase === "installed" ? outcome.result : null; const externalAccessFailure = state.status === "ready" && !!state.view.externalAccess; const errorBar = @@ -237,9 +238,16 @@ export default function App() { codePreviewRef.current?.jumpToLine(line) }} /> )} - + > = { matchaboutblank: "match", }; +/** 传给权限行的兼容性标记与跳转入口 */ +export interface CompatView { + marks: CompatMarks; + /** 跳到代码预览的指定行;无预览可跳时不传 */ + onJump?: (line: number) => void; +} + +/** 该权限行要额外呈现的不生效指令(只有 match 组落在既有权限行上,其余归「其他指令」) */ +export const tagsForGroup = (marks: CompatMarks, group: IneffectiveTagGroup): IneffectiveTag[] => + marks.tags.filter((tag) => tag.group === group); + +/** 不生效项总数,用于卡头徽章 */ +export const compatMarkCount = (marks: CompatMarks): number => marks.grants.size + marks.tags.length; + /** * 派生安装页的兼容性标记:脚本写了、但脚本猫不会执行的指令与 GM 能力。 * 判定是二元的(见 script_compat.ts),这里只负责定位与归组,不再分兼容程度。 diff --git a/src/pages/install/components/CodePreview.test.tsx b/src/pages/install/components/CodePreview.test.tsx index 8bf1029d4..1915b5b13 100644 --- a/src/pages/install/components/CodePreview.test.tsx +++ b/src/pages/install/components/CodePreview.test.tsx @@ -1,11 +1,12 @@ +import { createRef, type ComponentRef } from "react"; import { describe, it, expect, vi, beforeAll, afterEach } from "vitest"; -import { render, screen, cleanup, fireEvent } from "@testing-library/react"; +import { render, screen, cleanup, fireEvent, act } from "@testing-library/react"; import { initTestLanguage } from "@Tests/initTestLanguage"; // Monaco 无法在 DOM 测试环境中渲染(需 worker),用轻量桩替换,仅暴露 props 供断言接线 vi.mock("@App/pages/components/CodeEditor", () => import("@Tests/mocks/CodeEditor.tsx")); -import { setEditorMounts } from "@Tests/mocks/CodeEditor"; +import { setEditorMounts, revealLine } from "@Tests/mocks/CodeEditor"; import { CodePreview } from "./CodePreview"; const code = "// line1\nconst a = 1;\nconsole.log(a);"; @@ -79,3 +80,37 @@ describe("CodePreview 编辑器加载期的占位", () => { expect(screen.queryByTestId("code-skeleton")).not.toBeInTheDocument(); }); }); + +describe("CodePreview 跳到指定行", () => { + afterEach(() => { + revealLine.calls.length = 0; + setEditorMounts(true); + }); + + it("跳转把编辑器定位到该行", async () => { + const ref = createRef>(); + render(); + await act(async () => ref.current!.jumpToLine(2)); + expect(revealLine.calls).toEqual([2]); + }); + + it("代码卡折叠时先展开再定位——移动端默认折叠,点了却什么都没发生说不过去", async () => { + const ref = createRef>(); + render(); + expect(screen.queryByTestId("code-body")).not.toBeInTheDocument(); + await act(async () => ref.current!.jumpToLine(3)); + expect(screen.getByTestId("code-body")).toBeInTheDocument(); + expect(revealLine.calls).toEqual([3]); + }); + + it("编辑器还没就绪时把定位排队,就绪后补上", async () => { + setEditorMounts(false); + const ref = createRef>(); + const { rerender } = render(); + await act(async () => ref.current!.jumpToLine(2)); + expect(revealLine.calls).toEqual([]); + setEditorMounts(true); + await act(async () => rerender()); + expect(revealLine.calls).toEqual([2]); + }); +}); diff --git a/src/pages/install/components/CodePreview.tsx b/src/pages/install/components/CodePreview.tsx index ad69f74ad..05e0fde20 100644 --- a/src/pages/install/components/CodePreview.tsx +++ b/src/pages/install/components/CodePreview.tsx @@ -1,7 +1,7 @@ -import { useMemo, useState } from "react"; +import { useImperativeHandle, useMemo, useRef, useState, type Ref } from "react"; import { useTranslation } from "react-i18next"; import { CodeXml, Copy, Check, ChevronDown, ChevronRight } from "lucide-react"; -import CodeEditor from "@App/pages/components/CodeEditor"; +import CodeEditor, { type CodeEditorHandle } from "@App/pages/components/CodeEditor"; import { Skeleton } from "@App/pages/components/ui/skeleton"; import { cn } from "@App/pkg/utils/cn"; @@ -16,7 +16,13 @@ const CODE_SKELETON_LINES = [ "ml-4 w-[62%]", ]; +export interface CodePreviewHandle { + /** 展开代码卡并滚动到指定行 */ + jumpToLine: (line: number) => void; +} + export interface CodePreviewProps { + ref?: Ref; code: string; /** 更新态的旧版本代码;与 code 不同则触发内联 diff,全新安装为 undefined */ oldCode?: string; @@ -26,6 +32,7 @@ export interface CodePreviewProps { } export function CodePreview({ + ref, code, oldCode, language = "JavaScript", @@ -36,6 +43,33 @@ export function CodePreview({ const [collapsed, setCollapsed] = useState(defaultCollapsed); const [copied, setCopied] = useState(false); const [editorReady, setEditorReady] = useState(false); + const editorRef = useRef(null); + const sectionRef = useRef(null); + // 折叠态下编辑器实例尚未创建,跳转请求先排队,等 onReady 再补上定位 + const pendingLineRef = useRef(null); + + const revealLine = (line: number) => { + if (editorRef.current) editorRef.current.revealLine(line); + else pendingLineRef.current = line; + }; + + useImperativeHandle(ref, () => ({ + jumpToLine: (line: number) => { + setCollapsed(false); + sectionRef.current?.scrollIntoView?.({ behavior: "smooth", block: "nearest" }); + if (editorReady) revealLine(line); + else pendingLineRef.current = line; + }, + })); + + const handleReady = () => { + setEditorReady(true); + const pending = pendingLineRef.current; + if (pending !== null) { + pendingLineRef.current = null; + revealLine(pending); + } + }; const lineCount = useMemo(() => code.split("\n").length, [code]); // diffCode 语义:""=无 diff(普通只读预览),有值=内联 diff;切勿传 undefined(表示不加载) @@ -48,7 +82,7 @@ export function CodePreview({ }; return ( -
+
{t("editor:code")} @@ -99,11 +133,12 @@ export function CodePreview({
)} setEditorReady(true)} + onReady={handleReady} className="h-full w-full" /> diff --git a/src/pages/install/components/CompatChip.test.tsx b/src/pages/install/components/CompatChip.test.tsx new file mode 100644 index 000000000..afe871952 --- /dev/null +++ b/src/pages/install/components/CompatChip.test.tsx @@ -0,0 +1,77 @@ +import { describe, it, expect, beforeAll, afterEach, vi } from "vitest"; +import { render, screen, cleanup, fireEvent } from "@testing-library/react"; +import { initTestLanguage } from "@Tests/initTestLanguage"; +import { CompatChip } from "./CompatChip"; + +beforeAll(() => initTestLanguage("zh-CN")); +afterEach(cleanup); + +describe("CompatChip 不生效标记", () => { + it("渲染取值本身,并带上不生效的可读标注——含义不只靠颜色传达", () => { + render(); + const chip = screen.getByTestId("compat-chip"); + expect(chip).toHaveTextContent("GM_audio"); + expect(chip).toHaveAccessibleName(expect.stringContaining("不生效")); + }); + + it("鼠标移入弹出说明:GM 能力说清调用会报错", () => { + render(); + fireEvent.mouseEnter(screen.getByTestId("compat-chip")); + expect(screen.getByText("脚本猫未实现该 API,脚本调用时会报错,依赖它的功能不可用。")).toBeInTheDocument(); + }); + + it("鼠标移入弹出说明:元数据指令说清会被忽略", () => { + render(); + fireEvent.mouseEnter(screen.getByTestId("compat-chip")); + expect(screen.getByText("脚本猫不支持该声明,安装后会被忽略。")).toBeInTheDocument(); + }); + + it("移出后收起说明", () => { + render(); + const chip = screen.getByTestId("compat-chip"); + fireEvent.mouseEnter(chip); + expect(screen.getByTestId("compat-popover")).toBeInTheDocument(); + fireEvent.mouseLeave(chip); + expect(screen.queryByTestId("compat-popover")).not.toBeInTheDocument(); + }); + + it("键盘聚焦同样弹出说明——没有鼠标也拿得到这段信息", () => { + render(); + fireEvent.focus(screen.getByTestId("compat-chip")); + expect(screen.getByTestId("compat-popover")).toBeInTheDocument(); + }); + + it("点击跳到该指令所在行", () => { + const onJump = vi.fn(); + render(); + fireEvent.click(screen.getByTestId("compat-chip")); + expect(onJump).toHaveBeenCalledWith(7); + }); + + it("浮层给出行号,让用户知道点下去会去哪", () => { + render(); + fireEvent.mouseEnter(screen.getByTestId("compat-chip")); + expect(screen.getByText("跳到第 7 行")).toBeInTheDocument(); + }); + + it("代码里定位不到时仍然成条,只是不可跳转", () => { + const onJump = vi.fn(); + render(); + const chip = screen.getByTestId("compat-chip"); + fireEvent.click(chip); + expect(onJump).not.toHaveBeenCalled(); + fireEvent.mouseEnter(chip); + expect(screen.getByTestId("compat-popover")).toBeInTheDocument(); + expect(screen.queryByText(/跳到第/)).not.toBeInTheDocument(); + }); + + it("浮层提供兼容性文档链接,元数据与 GM 能力各自指向对应文档页", () => { + const { unmount } = render(); + fireEvent.mouseEnter(screen.getByTestId("compat-chip")); + expect(screen.getByTestId("compat-docs")).toHaveAttribute("href", expect.stringContaining("/docs/dev/meta")); + unmount(); + render(); + fireEvent.mouseEnter(screen.getByTestId("compat-chip")); + expect(screen.getByTestId("compat-docs")).toHaveAttribute("href", expect.stringContaining("/docs/dev/api")); + }); +}); diff --git a/src/pages/install/components/CompatChip.tsx b/src/pages/install/components/CompatChip.tsx new file mode 100644 index 000000000..cf814950e --- /dev/null +++ b/src/pages/install/components/CompatChip.tsx @@ -0,0 +1,92 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Ban, ExternalLink } from "lucide-react"; +import { DocumentationSite } from "@App/app/const"; +import { localePath } from "@App/locales/locales"; +import { Popover, PopoverAnchor, PopoverContent } from "@App/pages/components/ui/popover"; + +export type CompatChipKind = "metadata" | "grant"; + +const DOC_PATH: Record = { + metadata: "/docs/dev/meta", + grant: "/docs/dev/api", +}; + +const DESC_KEY: Record = { + metadata: "install:compat_tag_desc", + grant: "install:compat_grant_desc", +}; + +/** + * 写了但不会生效的指令 / GM 能力。就近标在它所属的权限行上,不单独成卡。 + * 说明走浮层(hover 与键盘聚焦都能开),点击本体跳到代码对应行—— + * 点击恒为跳转,不做浮层开关,否则「点一下跳过去」这个主动作会被折叠状态吃掉。 + */ +export function CompatChip({ + label, + kind, + line, + onJump, +}: { + label: string; + kind: CompatChipKind; + line?: number; + onJump?: (line: number) => void; +}) { + const { t } = useTranslation(["install", "common"]); + const [open, setOpen] = useState(false); + const canJump = line !== undefined && !!onJump; + + return ( + + + + + e.preventDefault()} + > +

+

+

{t(DESC_KEY[kind])}

+
+ {canJump && ( + + {t("install:compat_jump", { line })} + + )} + + {t("install:compat_docs")} + +
+
+
+ ); +} diff --git a/src/pages/install/components/PermissionCard.test.tsx b/src/pages/install/components/PermissionCard.test.tsx index 605c48c61..8dde86bd2 100644 --- a/src/pages/install/components/PermissionCard.test.tsx +++ b/src/pages/install/components/PermissionCard.test.tsx @@ -198,3 +198,78 @@ describe("PermissionCard 更新零变化态", () => { expect(screen.queryByText("api.a.com")).not.toBeInTheDocument(); }); }); + +describe("PermissionCard 上的不生效标记", () => { + const rows: PermissionRow[] = [ + { kind: "match", risk: "normal", values: ["*://a.com/*"], sensitive: [] }, + { kind: "grant", risk: "warn", values: ["GM_setValue", "GM_audio"], sensitive: [] }, + ]; + + it("卡头给出不生效项总数,权限与 GM 能力合并计数", () => { + render( + + ); + expect(screen.getByText("2 项不生效")).toBeInTheDocument(); + }); + + it("归不到任何权限类别的指令单独成行,只在有内容时出现", () => { + render( + + ); + const row = screen.getByTestId("permission-row-other"); + expect(row).toHaveTextContent("其他声明"); + expect(within(row).getByTestId("compat-chip")).toHaveTextContent("@sandbox"); + }); + + it("没有不生效项时既无徽章也无其他声明行——全兼容的安装页一字不改", () => { + render(); + expect(screen.queryByTestId("permission-row-other")).not.toBeInTheDocument(); + expect(screen.queryByText(/项不生效/)).not.toBeInTheDocument(); + }); +}); + +describe("不生效标记与折叠形态的关系", () => { + const rows: PermissionRow[] = [ + { kind: "grant", risk: "warn", values: ["GM_audio"], sensitive: [], diff: { added: [], removed: [] } }, + ]; + + it("权限一项没变但有不生效项时整卡不塌——塌了这些标记就没人看得见", () => { + render( + + ); + expect(screen.queryByTestId("permission-card-collapsed")).not.toBeInTheDocument(); + expect(screen.getByTestId("compat-chip")).toHaveTextContent("GM_audio"); + }); + + it("没有不生效项时仍按原样塌成单行", () => { + render(); + expect(screen.getByTestId("permission-card-collapsed")).toBeInTheDocument(); + }); +}); + +describe("移动端的不生效标记", () => { + it("有不生效项的类别默认展开,否则标记藏在折叠面板里等于没做", () => { + mobile = true; + render( + + ); + mobile = false; + expect(screen.getByTestId("compat-chip")).toBeVisible(); + }); +}); diff --git a/src/pages/install/components/PermissionCard.tsx b/src/pages/install/components/PermissionCard.tsx index c989d5be9..836573930 100644 --- a/src/pages/install/components/PermissionCard.tsx +++ b/src/pages/install/components/PermissionCard.tsx @@ -1,19 +1,24 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { ChevronDown, ShieldCheck } from "lucide-react"; +import { ChevronDown, FileCode2, ShieldCheck } from "lucide-react"; import { cn } from "@App/pkg/utils/cn"; import { useIsMobile } from "@App/pages/components/use-is-mobile"; import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from "@App/pages/components/ui/accordion"; +import { compatMarkCount, tagsForGroup, type CompatView } from "../compat"; +import { CompatChip } from "./CompatChip"; import { isPermissionChanged, type PermissionRow as PermissionRowData } from "../permissions"; import { PermissionRow, PermissionChips, PermissionDelta, NoChangeTag, KIND_META, RISK_STYLE } from "./PermissionRow"; -function MobilePermissions({ rows }: { rows: PermissionRowData[] }) { +function MobilePermissions({ rows, compat }: { rows: PermissionRowData[]; compat?: CompatView }) { const { t } = useTranslation(["install", "common"]); // 有变动时默认只展开有变动的类别;全新安装、以及用户主动点开的零变化整卡都退回只展开高风险项, // 否则零变化整卡展开后每一类都是收起的,「点开即得到全量清单」在移动端会落空。 const hasChanged = rows.some(isPermissionChanged); + const isMarked = (row: PermissionRowData) => + (row.kind === "grant" && row.values.some((v) => compat?.marks.grants.has(v))) || + (row.kind === "match" && tagsForGroup(compat?.marks ?? { grants: new Map(), tags: [] }, "match").length > 0); const defaultValue = rows - .filter((r) => (hasChanged ? isPermissionChanged(r) : r.risk === "danger")) + .filter((r) => isMarked(r) || (hasChanged ? isPermissionChanged(r) : r.risk === "danger")) .map((r) => r.kind); return ( @@ -37,7 +42,7 @@ function MobilePermissions({ rows }: { rows: PermissionRowData[] }) { - + ); @@ -47,13 +52,13 @@ function MobilePermissions({ rows }: { rows: PermissionRowData[] }) { } /** 未变动类别的单行形态:名称、计数与「无变化」,点开即还原成完整权限行 */ -function CollapsedRow({ row }: { row: PermissionRowData }) { +function CollapsedRow({ row, compat }: { row: PermissionRowData; compat?: CompatView }) { const { t } = useTranslation(["install", "common"]); const [open, setOpen] = useState(false); const { icon: Icon, labelKey } = KIND_META[row.kind]; const style = RISK_STYLE[row.risk]; - if (open) return ; + if (open) return ; return (
); diff --git a/src/pages/install/components/PermissionRow.test.tsx b/src/pages/install/components/PermissionRow.test.tsx index 36b11c75a..63b61032e 100644 --- a/src/pages/install/components/PermissionRow.test.tsx +++ b/src/pages/install/components/PermissionRow.test.tsx @@ -1,6 +1,7 @@ import { describe, it, expect, beforeAll, afterEach } from "vitest"; import { render, screen, cleanup, within, fireEvent } from "@testing-library/react"; import { initTestLanguage } from "@Tests/initTestLanguage"; +import type { IneffectiveTag } from "../compat"; import { PermissionRow } from "./PermissionRow"; beforeAll(() => initTestLanguage("zh-CN")); @@ -149,3 +150,59 @@ describe("PermissionRow 零变动行的取值折叠", () => { expect(within(row).queryByTestId("permission-more")).not.toBeInTheDocument(); }); }); + +describe("PermissionRow 上的不生效标记", () => { + const compat = (over: Partial<{ grants: Map; tags: IneffectiveTag[] }> = {}) => ({ + marks: { grants: new Map(), tags: [], ...over }, + }); + + it("不受支持的 GM 能力就地换成不生效标记,其余 chip 不变", () => { + render( + + ); + const marks = screen.getAllByTestId("compat-chip"); + expect(marks).toHaveLength(1); + expect(marks[0]).toHaveTextContent("GM_audio"); + expect(screen.getByText("GM_setValue")).toBeInTheDocument(); + expect(screen.getByText("GM_setValue").closest('[data-testid="compat-chip"]')).toBeNull(); + }); + + it("不生效的匹配类指令追加到运行网站行——它本该影响的就是这一行", () => { + render( + + ); + expect(screen.getByTestId("compat-chip")).toHaveTextContent("@exclude-match"); + }); + + it("其他类别的行不会被别的组的标记污染", () => { + render( + + ); + expect(screen.queryByTestId("compat-chip")).not.toBeInTheDocument(); + }); + + it("更新态里被移除的能力不标记——它已经不在新版本里了", () => { + render( + + ); + expect(screen.queryByTestId("compat-chip")).not.toBeInTheDocument(); + }); +}); diff --git a/src/pages/install/components/PermissionRow.tsx b/src/pages/install/components/PermissionRow.tsx index c83d703fe..db79fcbec 100644 --- a/src/pages/install/components/PermissionRow.tsx +++ b/src/pages/install/components/PermissionRow.tsx @@ -2,6 +2,8 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; import { Globe, ArrowLeftRight, ChevronDown, KeyRound, Package, TriangleAlert, type LucideIcon } from "lucide-react"; import { cn } from "@App/pkg/utils/cn"; +import { tagsForGroup, type CompatView } from "../compat"; +import { CompatChip } from "./CompatChip"; import { isPermissionChanged, type PermissionKind, @@ -101,22 +103,38 @@ function MoreButton({ label, onClick }: { label: string; onClick: () => void }) export function PermissionChips({ row, maxVisible = DEFAULT_MAX_VISIBLE, + compat, }: { row: PermissionRowData; maxVisible?: number; + compat?: CompatView; }) { const { t } = useTranslation(["install", "common"]); const [expanded, setExpanded] = useState(false); + // 不受支持的 @grant 就地换成不生效标记;已被移除的取值不标——它已经不在新版本里了 + const renderChip = (value: string, change?: ChangeState) => { + const line = row.kind === "grant" && change !== "removed" ? compat?.marks.grants.get(value) : undefined; + if (row.kind === "grant" && change !== "removed" && compat?.marks.grants.has(value)) { + return ; + } + return ; + }; + + // 权限卡里没有对应 chip 的不生效指令,追加到它本该影响的这一行 + const appended = compat && row.kind === "match" ? tagsForGroup(compat.marks, "match") : []; + const appendedChips = appended.map((tag) => ( + + )); + if (!row.diff) { const visible = expanded ? row.values : row.values.slice(0, maxVisible); const hidden = row.values.length - visible.length; return (
- {visible.map((v) => ( - - ))} + {visible.map((v) => renderChip(v))} {hidden > 0 && setExpanded(true)} />} + {appendedChips}
); } @@ -132,21 +150,16 @@ export function PermissionChips({ return (
- {added.map((v) => ( - - ))} - {removed.map((v) => ( - - ))} - {visibleUnchanged.map((v) => ( - - ))} + {added.map((v) => renderChip(v, "added"))} + {removed.map((v) => renderChip(v, "removed"))} + {visibleUnchanged.map((v) => renderChip(v, "unchanged"))} {hidden > 0 && ( 0 ? t("install:perm_unchanged_more", { count: hidden }) : `+${hidden}`} onClick={() => setExpanded(true)} /> )} + {appendedChips}
); } @@ -173,7 +186,15 @@ export function PermissionDelta({ row }: { row: PermissionRowData }) { ); } -export function PermissionRow({ row, maxVisible }: { row: PermissionRowData; maxVisible?: number }) { +export function PermissionRow({ + row, + maxVisible, + compat, +}: { + row: PermissionRowData; + maxVisible?: number; + compat?: CompatView; +}) { const { t } = useTranslation(["install", "common"]); const { icon: Icon, labelKey, summaryKey } = KIND_META[row.kind]; const style = RISK_STYLE[row.risk]; @@ -191,7 +212,7 @@ export function PermissionRow({ row, maxVisible }: { row: PermissionRowData; max {row.diff && !isPermissionChanged(row) && } {t(summaryKey)} - + ); diff --git a/src/pages/install/useInstallData.test.ts b/src/pages/install/useInstallData.test.ts index caee21fd2..663255b4f 100644 --- a/src/pages/install/useInstallData.test.ts +++ b/src/pages/install/useInstallData.test.ts @@ -117,6 +117,24 @@ describe("assembleInstallView 组装安装视图", () => { expect(assembleInstallView(base).inTrash).toBe(false); }); + it("组装时派生不生效标记,行号取自待安装代码", () => { + const metadata = { name: ["示例脚本"], version: ["2.3.1"], "exclude-match": ["*://a.com/*"], grant: ["GM_audio"] }; + const code = `// ==UserScript== +// @name 示例脚本 +// @exclude-match *://a.com/* +// @grant GM_audio +// ==/UserScript==`; + const view = assembleInstallView({ + isUpdate: false, + scriptInfo: makeScriptInfo(metadata), + action: makeAction(metadata), + code, + oldVersion: null, + }); + expect(view.compat.grants).toEqual(new Map([["GM_audio", 4]])); + expect(view.compat.tags).toEqual([{ tag: "exclude-match", group: "match", line: 3 }]); + }); + it("全新安装组装名称、来源、版本与权限", () => { const metadata = { name: ["示例脚本"], diff --git a/src/pages/install/useInstallData.ts b/src/pages/install/useInstallData.ts index 64683a847..9c6eeaee5 100644 --- a/src/pages/install/useInstallData.ts +++ b/src/pages/install/useInstallData.ts @@ -29,6 +29,7 @@ import { type ScheduleInfo, type DiffStat, } from "./model"; +import { deriveCompatMarks, type CompatMarks } from "./compat"; export interface InstallView { isUpdate: boolean; @@ -54,6 +55,8 @@ export interface InstallView { subscribeScripts: string[]; /** 由 MCP 客户端请求安装时附加;非 MCP 来源为 undefined */ externalAccess?: ScriptInfo["externalAccess"]; + /** 写了但脚本猫不会执行的指令与 GM 能力,就近标在权限行上 */ + compat: CompatMarks; } /** @@ -93,6 +96,7 @@ export function assembleInstallView(args: { diffStat: oldCode !== undefined && oldCode !== code ? deriveDiffStat(oldCode, code) : undefined, subscribeScripts: scriptInfo.userSubscribe ? metadata.scripturl || [] : [], externalAccess: scriptInfo.externalAccess, + compat: deriveCompatMarks(metadata, code), }; } diff --git a/tests/mocks/CodeEditor.tsx b/tests/mocks/CodeEditor.tsx index 2d9b93f47..6bde3d896 100644 --- a/tests/mocks/CodeEditor.tsx +++ b/tests/mocks/CodeEditor.tsx @@ -1,25 +1,38 @@ -import { useEffect } from "react"; +import { useEffect, useImperativeHandle, type Ref } from "react"; -// 真实编辑器要等偏好设置读出来才创建实例并回调 onEditorMount。默认模拟「已就绪」, +// 真实编辑器要等偏好设置读出来才创建实例并回调。默认模拟「已就绪」, // 需要停在就绪之前(例如断言代码骨架)的用例调 setEditorMounts(false)。 let editorMounts = true; export function setEditorMounts(v: boolean) { editorMounts = v; } +export const revealLine = { calls: [] as number[] }; + export default function MockCodeEditor({ id, code, diffCode, onEditorMount, + onReady, + ref, }: { id: string; code?: string; diffCode?: string; onEditorMount?: (editor: unknown) => void; + onReady?: () => void; + ref?: Ref<{ editor: unknown; revealLine: (line: number) => void }>; }) { + useImperativeHandle(ref, () => ({ + editor: {}, + revealLine: (line: number) => revealLine.calls.push(line), + })); useEffect(() => { - if (editorMounts) onEditorMount?.({}); - }, [onEditorMount]); + if (!editorMounts) return; + // 与真实实现一致:diff 预览没有可编辑实例,只报告就绪 + if (!diffCode) onEditorMount?.({}); + onReady?.(); + }, [diffCode, onEditorMount, onReady]); return
; } From 0aa072756b3050d05475f1bff50e06c4c6598bd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Mon, 14 Sep 2026 14:35:20 +0800 Subject: [PATCH 3/9] =?UTF-8?q?=F0=9F=90=9B=20=E4=B8=8D=E7=94=9F=E6=95=88?= =?UTF-8?q?=E6=A0=87=E8=AE=B0=E7=9A=84=E6=B5=AE=E5=B1=82=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E5=90=9E=E6=8E=89=E6=96=87=E6=A1=A3=E9=93=BE=E6=8E=A5=E4=B8=8E?= =?UTF-8?q?=E7=9B=B8=E9=82=BB=E6=B5=AE=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 真实会话验证时发现两处:浮层里有「兼容性文档」链接,但只有 chip 自己在跟踪悬停, 鼠标一离开 chip 去点链接浮层就关了,链接实际上点不到;标记 chip 常常并排,浮层宽 288px,切换悬停时旧浮层要等关闭延迟才收,两枚会同时开着互相盖住。 改用仓库已有的 useHoverMenu,把浮层本体一并纳入悬停范围;再以模块级「当前开着的 浮层」做互斥,悬停切换时立刻收掉上一枚。 --- .../install/components/CompatChip.test.tsx | 60 ++++++++++++++++--- src/pages/install/components/CompatChip.tsx | 42 ++++++++----- 2 files changed, 80 insertions(+), 22 deletions(-) diff --git a/src/pages/install/components/CompatChip.test.tsx b/src/pages/install/components/CompatChip.test.tsx index afe871952..6da8e8b30 100644 --- a/src/pages/install/components/CompatChip.test.tsx +++ b/src/pages/install/components/CompatChip.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, beforeAll, afterEach, vi } from "vitest"; -import { render, screen, cleanup, fireEvent } from "@testing-library/react"; +import { render, screen, cleanup, fireEvent, act } from "@testing-library/react"; import { initTestLanguage } from "@Tests/initTestLanguage"; import { CompatChip } from "./CompatChip"; @@ -26,13 +26,39 @@ describe("CompatChip 不生效标记", () => { expect(screen.getByText("脚本猫不支持该声明,安装后会被忽略。")).toBeInTheDocument(); }); - it("移出后收起说明", () => { - render(); - const chip = screen.getByTestId("compat-chip"); - fireEvent.mouseEnter(chip); - expect(screen.getByTestId("compat-popover")).toBeInTheDocument(); - fireEvent.mouseLeave(chip); - expect(screen.queryByTestId("compat-popover")).not.toBeInTheDocument(); + it("移出后收起说明", async () => { + vi.useFakeTimers(); + try { + render(); + const chip = screen.getByTestId("compat-chip"); + fireEvent.mouseEnter(chip); + expect(screen.getByTestId("compat-popover")).toBeInTheDocument(); + fireEvent.mouseLeave(chip); + await act(async () => { + await vi.advanceTimersByTimeAsync(400); + }); + expect(screen.queryByTestId("compat-popover")).not.toBeInTheDocument(); + } finally { + vi.useRealTimers(); + } + }); + + it("鼠标从 chip 移到浮层上不会关闭——否则浮层里的文档链接永远点不到", async () => { + vi.useFakeTimers(); + try { + render(); + fireEvent.mouseEnter(screen.getByTestId("compat-chip")); + const popover = screen.getByTestId("compat-popover"); + fireEvent.mouseEnter(popover); + fireEvent.mouseLeave(screen.getByTestId("compat-chip")); + await act(async () => { + await vi.advanceTimersByTimeAsync(400); + }); + expect(screen.getByTestId("compat-popover")).toBeInTheDocument(); + expect(screen.getByTestId("compat-docs")).toBeInTheDocument(); + } finally { + vi.useRealTimers(); + } }); it("键盘聚焦同样弹出说明——没有鼠标也拿得到这段信息", () => { @@ -75,3 +101,21 @@ describe("CompatChip 不生效标记", () => { expect(screen.getByTestId("compat-docs")).toHaveAttribute("href", expect.stringContaining("/docs/dev/api")); }); }); + +describe("多枚标记之间的浮层互斥", () => { + it("悬停另一枚 chip 时上一枚的浮层立刻收起——两枚浮层会互相盖住", () => { + render( + <> + + + + ); + const [first, second] = screen.getAllByTestId("compat-chip"); + fireEvent.mouseEnter(first); + expect(screen.getAllByTestId("compat-popover")).toHaveLength(1); + fireEvent.mouseEnter(second); + const open = screen.getAllByTestId("compat-popover"); + expect(open).toHaveLength(1); + expect(open[0]).toHaveTextContent("GM_audio"); + }); +}); diff --git a/src/pages/install/components/CompatChip.tsx b/src/pages/install/components/CompatChip.tsx index cf814950e..40fba35ef 100644 --- a/src/pages/install/components/CompatChip.tsx +++ b/src/pages/install/components/CompatChip.tsx @@ -1,12 +1,17 @@ -import { useState } from "react"; +import { useEffect } from "react"; import { useTranslation } from "react-i18next"; import { Ban, ExternalLink } from "lucide-react"; import { DocumentationSite } from "@App/app/const"; import { localePath } from "@App/locales/locales"; import { Popover, PopoverAnchor, PopoverContent } from "@App/pages/components/ui/popover"; +import { useHoverMenu } from "@App/pages/components/ui/use-hover-menu"; export type CompatChipKind = "metadata" | "grant"; +// 同一时刻只开一枚浮层:标记 chip 常常并排,浮层有 288px 宽,两枚同时开会互相盖住。 +// 悬停切换时旧浮层要等关闭延迟才收,靠这里立刻收掉。 +let closeActivePopover: (() => void) | null = null; + const DOC_PATH: Record = { metadata: "/docs/dev/meta", grant: "/docs/dev/api", @@ -34,21 +39,35 @@ export function CompatChip({ onJump?: (line: number) => void; }) { const { t } = useTranslation(["install", "common"]); - const [open, setOpen] = useState(false); + // 浮层里有文档链接,必须把浮层本体也纳入悬停范围:只盯 chip 的话鼠标一移向链接浮层就关了 + const { rootProps, hoverProps, contentProps, close } = useHoverMenu(150); const canJump = line !== undefined && !!onJump; + // close 来自 useHoverMenu 的 useCallback([]),恒定,故清理只在卸载时发生 + useEffect( + () => () => { + if (closeActivePopover === close) closeActivePopover = null; + }, + [close] + ); + + const openPopover = () => { + if (closeActivePopover && closeActivePopover !== close) closeActivePopover(); + closeActivePopover = close; + hoverProps.onMouseEnter(); + }; return ( - + - e.preventDefault()} - > +

{visible.map((v) => renderChip(v))} {hidden > 0 && setExpanded(true)} />} - {appendedChips}
); } @@ -159,7 +152,6 @@ export function PermissionChips({ onClick={() => setExpanded(true)} /> )} - {appendedChips}
); } diff --git a/src/pages/install/useInstallData.test.ts b/src/pages/install/useInstallData.test.ts index 663255b4f..d39635cea 100644 --- a/src/pages/install/useInstallData.test.ts +++ b/src/pages/install/useInstallData.test.ts @@ -132,7 +132,7 @@ describe("assembleInstallView 组装安装视图", () => { oldVersion: null, }); expect(view.compat.grants).toEqual(new Map([["GM_audio", 4]])); - expect(view.compat.tags).toEqual([{ tag: "exclude-match", group: "match", line: 3 }]); + expect(view.compat.tags).toEqual([{ tag: "exclude-match", line: 3 }]); }); it("全新安装组装名称、来源、版本与权限", () => { From a3c3897596c03873cc5d4a1204cad12f6233cce4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Wed, 16 Sep 2026 16:13:10 +0800 Subject: [PATCH 5/9] =?UTF-8?q?=E2=9C=A8=20=E5=AE=89=E8=A3=85=E9=A1=B5?= =?UTF-8?q?=E6=A0=87=E5=87=BA=E4=BB=85=E9=99=90=E8=84=9A=E6=9C=AC=E7=8C=AB?= =?UTF-8?q?=E7=9A=84=20CAT=20=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CAT_ / CAT. 是脚本猫自有命名空间,装在别的脚本管理器里不可用,在 GM 能力 chip 上就地注明。 --- src/locales/de-DE/install.json | 1 + src/locales/en-US/install.json | 1 + src/locales/ja-JP/install.json | 1 + src/locales/ko-KR/install.json | 1 + src/locales/pt-BR/install.json | 1 + src/locales/ru-RU/install.json | 1 + src/locales/tr-TR/install.json | 1 + src/locales/vi-VN/install.json | 1 + src/locales/zh-CN/install.json | 1 + src/locales/zh-TW/install.json | 1 + .../install/components/PermissionRow.test.tsx | 36 +++++++++++++++++++ .../install/components/PermissionRow.tsx | 11 ++++++ src/pkg/utils/script_compat.test.ts | 15 ++++++++ src/pkg/utils/script_compat.ts | 3 ++ 14 files changed, 75 insertions(+) diff --git a/src/locales/de-DE/install.json b/src/locales/de-DE/install.json index 045519f21..be51d04fe 100644 --- a/src/locales/de-DE/install.json +++ b/src/locales/de-DE/install.json @@ -261,6 +261,7 @@ "compat_jump": "Zu Zeile {{line}} springen", "compat_docs": "Kompatibilitätsdokumentation", "compat_count": "{{count}} ohne Wirkung", + "compat_scriptcat_only": "nur ScriptCat", "perm_other_label": "Weitere Deklarationen", "perm_other_summary": "Vom Skript deklariert, wird von ScriptCat aber nicht ausgeführt" } diff --git a/src/locales/en-US/install.json b/src/locales/en-US/install.json index 6949c896f..fa95e1d59 100644 --- a/src/locales/en-US/install.json +++ b/src/locales/en-US/install.json @@ -261,6 +261,7 @@ "compat_jump": "Go to line {{line}}", "compat_docs": "Compatibility docs", "compat_count": "{{count}} with no effect", + "compat_scriptcat_only": "ScriptCat only", "perm_other_label": "Other declarations", "perm_other_summary": "Declared by the script but not executed by ScriptCat" } diff --git a/src/locales/ja-JP/install.json b/src/locales/ja-JP/install.json index 81b48a379..5351805ef 100644 --- a/src/locales/ja-JP/install.json +++ b/src/locales/ja-JP/install.json @@ -261,6 +261,7 @@ "compat_jump": "{{line}} 行目へ移動", "compat_docs": "互換性ドキュメント", "compat_count": "無効な項目 {{count}} 件", + "compat_scriptcat_only": "ScriptCat 専用", "perm_other_label": "その他の宣言", "perm_other_summary": "宣言されていますが ScriptCat では実行されません" } diff --git a/src/locales/ko-KR/install.json b/src/locales/ko-KR/install.json index d99b8e78f..e4d3e6409 100644 --- a/src/locales/ko-KR/install.json +++ b/src/locales/ko-KR/install.json @@ -261,6 +261,7 @@ "compat_jump": "{{line}}번째 줄로 이동", "compat_docs": "호환성 문서", "compat_count": "적용되지 않는 항목 {{count}}개", + "compat_scriptcat_only": "ScriptCat 전용", "perm_other_label": "기타 선언", "perm_other_summary": "선언되었지만 ScriptCat이 실행하지 않습니다" } diff --git a/src/locales/pt-BR/install.json b/src/locales/pt-BR/install.json index 0990111b3..244237720 100644 --- a/src/locales/pt-BR/install.json +++ b/src/locales/pt-BR/install.json @@ -261,6 +261,7 @@ "compat_jump": "Ir para a linha {{line}}", "compat_docs": "Documentação de compatibilidade", "compat_count": "{{count}} sem efeito", + "compat_scriptcat_only": "Somente ScriptCat", "perm_other_label": "Outras declarações", "perm_other_summary": "Declarado pelo script, mas não executado pelo ScriptCat" } diff --git a/src/locales/ru-RU/install.json b/src/locales/ru-RU/install.json index 0798b3ac2..0ab5e9cf3 100644 --- a/src/locales/ru-RU/install.json +++ b/src/locales/ru-RU/install.json @@ -261,6 +261,7 @@ "compat_jump": "Перейти к строке {{line}}", "compat_docs": "Документация по совместимости", "compat_count": "Не действует: {{count}}", + "compat_scriptcat_only": "только ScriptCat", "perm_other_label": "Прочие объявления", "perm_other_summary": "Объявлено скриптом, но ScriptCat это не выполняет" } diff --git a/src/locales/tr-TR/install.json b/src/locales/tr-TR/install.json index 4cdc157a4..e32f3b089 100644 --- a/src/locales/tr-TR/install.json +++ b/src/locales/tr-TR/install.json @@ -261,6 +261,7 @@ "compat_jump": "{{line}}. satıra git", "compat_docs": "Uyumluluk belgeleri", "compat_count": "{{count}} etkisiz", + "compat_scriptcat_only": "Yalnızca ScriptCat", "perm_other_label": "Diğer bildirimler", "perm_other_summary": "Betikte bildirildi ancak ScriptCat tarafından çalıştırılmıyor" } diff --git a/src/locales/vi-VN/install.json b/src/locales/vi-VN/install.json index 20ff18f52..3ee2d270a 100644 --- a/src/locales/vi-VN/install.json +++ b/src/locales/vi-VN/install.json @@ -261,6 +261,7 @@ "compat_jump": "Đến dòng {{line}}", "compat_docs": "Tài liệu tương thích", "compat_count": "{{count}} mục không có tác dụng", + "compat_scriptcat_only": "Chỉ ScriptCat", "perm_other_label": "Khai báo khác", "perm_other_summary": "Script có khai báo nhưng ScriptCat không thực thi" } diff --git a/src/locales/zh-CN/install.json b/src/locales/zh-CN/install.json index 8afdd420e..105686d56 100644 --- a/src/locales/zh-CN/install.json +++ b/src/locales/zh-CN/install.json @@ -261,6 +261,7 @@ "compat_jump": "跳到第 {{line}} 行", "compat_docs": "兼容性文档", "compat_count": "{{count}} 项不生效", + "compat_scriptcat_only": "仅限脚本猫", "perm_other_label": "其他声明", "perm_other_summary": "已声明,但脚本猫不会执行" } diff --git a/src/locales/zh-TW/install.json b/src/locales/zh-TW/install.json index 1baf3ab38..459d401e1 100644 --- a/src/locales/zh-TW/install.json +++ b/src/locales/zh-TW/install.json @@ -261,6 +261,7 @@ "compat_jump": "跳至第 {{line}} 行", "compat_docs": "相容性文件", "compat_count": "{{count}} 項不生效", + "compat_scriptcat_only": "僅限腳本貓", "perm_other_label": "其他宣告", "perm_other_summary": "已宣告,但腳本貓不會執行" } diff --git a/src/pages/install/components/PermissionRow.test.tsx b/src/pages/install/components/PermissionRow.test.tsx index b80df191e..90695a61c 100644 --- a/src/pages/install/components/PermissionRow.test.tsx +++ b/src/pages/install/components/PermissionRow.test.tsx @@ -196,3 +196,39 @@ describe("PermissionRow 上的不生效标记", () => { expect(screen.queryByTestId("compat-chip")).not.toBeInTheDocument(); }); }); + +describe("PermissionRow 上的仅限脚本猫标记", () => { + it("CAT_ 与 CAT. 能力标出仅限脚本猫,通用 GM 能力不标", () => { + render( + + ); + const row = screen.getByTestId("permission-row"); + expect(within(row).getAllByTestId("scriptcat-only")).toHaveLength(2); + expect( + within(screen.getByText("CAT_fileStorage").closest("[data-chip]")!).getByText("仅限脚本猫") + ).toBeInTheDocument(); + expect(within(screen.getByText("GM_setValue").closest("[data-chip]")!).queryByTestId("scriptcat-only")).toBeNull(); + }); + + it("更新态里被移除的 CAT 能力不标——它已经不在新版本里了", () => { + render( + + ); + expect(screen.queryByTestId("scriptcat-only")).not.toBeInTheDocument(); + }); +}); diff --git a/src/pages/install/components/PermissionRow.tsx b/src/pages/install/components/PermissionRow.tsx index 606fcb21b..642acc8e7 100644 --- a/src/pages/install/components/PermissionRow.tsx +++ b/src/pages/install/components/PermissionRow.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; import { Globe, ArrowLeftRight, ChevronDown, KeyRound, Package, TriangleAlert, type LucideIcon } from "lucide-react"; import { cn } from "@App/pkg/utils/cn"; +import { isScriptCatOnlyGrant } from "@App/pkg/utils/script_compat"; import type { CompatView } from "../compat"; import { CompatChip } from "./CompatChip"; import { @@ -53,6 +54,8 @@ const CHANGE_LABEL_KEY: Record = { function Chip({ value, row, change }: { value: string; row: PermissionRowData; change?: ChangeState }) { const { t } = useTranslation(["install", "common"]); const isSensitive = row.sensitive.includes(value); + // 装在脚本猫里能用,但换到别的脚本管理器就不行了;已移除的取值不再属于新版本,不标 + const isScriptCatOnly = row.kind === "grant" && change !== "removed" && isScriptCatOnlyGrant(value); const base = isSensitive ? "border border-warning-fg bg-muted text-warning-fg" : RISK_STYLE[row.risk].chip; // 变动状态只用字重、描边与 +/− 记号表达,底色继续留给风险等级—— @@ -78,6 +81,14 @@ function Chip({ value, row, change }: { value: string; row: PermissionRowData; c {isSensitive && change !== "removed" && } {change && {t(CHANGE_LABEL_KEY[change])}} {value} + {isScriptCatOnly && ( + + {t("install:compat_scriptcat_only")} + + )} ); } diff --git a/src/pkg/utils/script_compat.test.ts b/src/pkg/utils/script_compat.test.ts index cef91decb..733f96282 100644 --- a/src/pkg/utils/script_compat.test.ts +++ b/src/pkg/utils/script_compat.test.ts @@ -8,6 +8,7 @@ import { SUPPORTED_METADATA_TAGS, SYNTHETIC_METADATA_TAGS, isSupportedGrant, + isScriptCatOnlyGrant, isSupportedMetadataTag, resolveMetadataTagBase, } from "./script_compat"; @@ -83,6 +84,20 @@ describe("GM 能力支持判定", () => { }); }); +describe("仅限脚本猫的 GM 能力判定", () => { + it("CAT_ 与 CAT. 命名空间下的已实现能力仅限脚本猫", () => { + for (const grant of ["CAT_fileStorage", "CAT_userConfig", "CAT.agent.dom"]) { + expect(isScriptCatOnlyGrant(grant), grant).toBe(true); + } + }); + + it("通用 GM 能力、上下文能力与脚本猫未实现的 CAT 名字都不算", () => { + for (const grant of ["GM_setValue", "GM.xmlHttpRequest", "unsafeWindow", "window.close", "CAT_notExist"]) { + expect(isScriptCatOnlyGrant(grant), grant).toBe(false); + } + }); +}); + describe("支持表与运行时注册表的一致性", () => { it("注册表里的每个 @grant 都在支持表内——新增 GM API 漏进表会在此转红", () => { const missing = GMContextApiNames().filter((name) => !SUPPORTED_GRANTS.has(name)); diff --git a/src/pkg/utils/script_compat.ts b/src/pkg/utils/script_compat.ts index 2a8ec950b..069937d38 100644 --- a/src/pkg/utils/script_compat.ts +++ b/src/pkg/utils/script_compat.ts @@ -206,3 +206,6 @@ export const SUPPORTED_GRANTS: ReadonlySet = new Set([...REGISTERED_GRAN // 与运行时同一套候选规则:@grant GM.foo 与 GM_foo 互认(src/app/service/content/gm_api/grant.ts) export const isSupportedGrant = (grant: string): boolean => getGrantCandidates(grant).some((candidate) => SUPPORTED_GRANTS.has(candidate)); + +// CAT_ / CAT. 是脚本猫自有的 API 命名空间,其他脚本管理器没有这些能力 +export const isScriptCatOnlyGrant = (grant: string): boolean => /^CAT[_.]/.test(grant) && isSupportedGrant(grant); From 0823786c76255bd51f76cbc0b13ee95c4f674cd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Wed, 16 Sep 2026 16:45:12 +0800 Subject: [PATCH 6/9] =?UTF-8?q?=E2=9C=A8=20=E5=AE=89=E8=A3=85=E9=A1=B5?= =?UTF-8?q?=E6=8C=89=E7=99=BD=E5=90=8D=E5=8D=95=E6=A0=87=E5=87=BA=E4=B8=8D?= =?UTF-8?q?=E7=94=9F=E6=95=88=E7=9A=84=E5=8F=96=E5=80=BC=E4=B8=8E=E8=84=9A?= =?UTF-8?q?=E6=9C=AC=E7=8C=AB=E7=8B=AC=E6=9C=89=E6=8C=87=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 指令名早已按支持表判定,但受支持指令的取值没有:运行时对不认得的 @run-at / @run-in / @inject-into 取值静默回退,@run-in container-id-2 这类写法会让脚本在任何标签页都不运行, 解析不出规则的 @match 被直接丢弃,安装页却一字不提。现在取值同样只认白名单, @match 交给运行时同一个解析器判定,只读第一个取值的指令标出被忽略的后续取值, @early-start 缺 @run-at document-start 时也标为不生效。 另把 @early-start、@background 等脚本猫独有的指令在「其他声明」行标出「仅限脚本猫」; 名单由测试对照 eslint-plugin-userscripts 收录的别家指令守卫。 --- src/locales/de-DE/install.json | 3 +- src/locales/en-US/install.json | 3 +- src/locales/ja-JP/install.json | 3 +- src/locales/ko-KR/install.json | 3 +- src/locales/pt-BR/install.json | 3 +- src/locales/ru-RU/install.json | 3 +- src/locales/tr-TR/install.json | 3 +- src/locales/vi-VN/install.json | 3 +- src/locales/zh-CN/install.json | 3 +- src/locales/zh-TW/install.json | 3 +- src/pages/install/App.test.tsx | 4 +- src/pages/install/compat.test.ts | 37 ++++++- src/pages/install/compat.ts | 66 +++++++++---- .../install/components/CompatChip.test.tsx | 6 ++ src/pages/install/components/CompatChip.tsx | 17 +++- .../components/PermissionCard.test.tsx | 55 ++++++++++- .../install/components/PermissionCard.tsx | 27 +++++- .../install/components/PermissionRow.test.tsx | 2 +- .../install/components/PermissionRow.tsx | 11 +-- src/pages/install/useInstallData.test.ts | 1 + src/pkg/utils/script_compat.test.ts | 97 +++++++++++++++++++ src/pkg/utils/script_compat.ts | 61 ++++++++++++ src/types/eslint-plugin-userscripts.d.ts | 8 ++ 23 files changed, 369 insertions(+), 53 deletions(-) create mode 100644 src/types/eslint-plugin-userscripts.d.ts diff --git a/src/locales/de-DE/install.json b/src/locales/de-DE/install.json index be51d04fe..594b3c4e3 100644 --- a/src/locales/de-DE/install.json +++ b/src/locales/de-DE/install.json @@ -257,11 +257,12 @@ "code_loading": "Code wird geladen", "compat_ineffective": "ohne Wirkung", "compat_tag_desc": "ScriptCat unterstützt diese Deklaration nicht; sie wird nach der Installation ignoriert.", + "compat_value_desc": "ScriptCat unterstützt diesen Wert nicht; die Deklaration wirkt nicht wie angegeben.", "compat_grant_desc": "ScriptCat implementiert diese API nicht; Aufrufe schlagen fehl und darauf aufbauende Funktionen arbeiten nicht.", "compat_jump": "Zu Zeile {{line}} springen", "compat_docs": "Kompatibilitätsdokumentation", "compat_count": "{{count}} ohne Wirkung", "compat_scriptcat_only": "nur ScriptCat", "perm_other_label": "Weitere Deklarationen", - "perm_other_summary": "Vom Skript deklariert, wird von ScriptCat aber nicht ausgeführt" + "perm_other_summary": "Verhält sich anders als in anderen Skriptmanagern" } diff --git a/src/locales/en-US/install.json b/src/locales/en-US/install.json index fa95e1d59..f2c235c5b 100644 --- a/src/locales/en-US/install.json +++ b/src/locales/en-US/install.json @@ -257,11 +257,12 @@ "code_loading": "Loading code", "compat_ineffective": "no effect", "compat_tag_desc": "ScriptCat does not support this declaration; it is ignored after installation.", + "compat_value_desc": "ScriptCat does not support this value; the declaration won't take effect as written.", "compat_grant_desc": "ScriptCat has not implemented this API; calls to it fail and features relying on it won't work.", "compat_jump": "Go to line {{line}}", "compat_docs": "Compatibility docs", "compat_count": "{{count}} with no effect", "compat_scriptcat_only": "ScriptCat only", "perm_other_label": "Other declarations", - "perm_other_summary": "Declared by the script but not executed by ScriptCat" + "perm_other_summary": "Behaves differently from other script managers" } diff --git a/src/locales/ja-JP/install.json b/src/locales/ja-JP/install.json index 5351805ef..1d90f035c 100644 --- a/src/locales/ja-JP/install.json +++ b/src/locales/ja-JP/install.json @@ -257,11 +257,12 @@ "code_loading": "コードを読み込み中", "compat_ineffective": "無効", "compat_tag_desc": "ScriptCat はこの宣言に対応していないため、インストール後は無視されます。", + "compat_value_desc": "ScriptCat はこの値に対応していないため、宣言は記述どおりには機能しません。", "compat_grant_desc": "ScriptCat はこの API を実装していないため、スクリプトから呼び出すとエラーになり、これに依存する機能は動作しません。", "compat_jump": "{{line}} 行目へ移動", "compat_docs": "互換性ドキュメント", "compat_count": "無効な項目 {{count}} 件", "compat_scriptcat_only": "ScriptCat 専用", "perm_other_label": "その他の宣言", - "perm_other_summary": "宣言されていますが ScriptCat では実行されません" + "perm_other_summary": "他のスクリプトマネージャーとは互換性が異なります" } diff --git a/src/locales/ko-KR/install.json b/src/locales/ko-KR/install.json index e4d3e6409..8dd74d50b 100644 --- a/src/locales/ko-KR/install.json +++ b/src/locales/ko-KR/install.json @@ -257,11 +257,12 @@ "code_loading": "코드를 불러오는 중", "compat_ineffective": "적용되지 않음", "compat_tag_desc": "ScriptCat은 이 선언을 지원하지 않으므로 설치 후 무시됩니다.", + "compat_value_desc": "ScriptCat은 이 값을 지원하지 않으므로 선언이 작성한 대로 적용되지 않습니다.", "compat_grant_desc": "ScriptCat은 이 API를 구현하지 않았습니다. 스크립트에서 호출하면 오류가 발생하고 이에 의존하는 기능은 동작하지 않습니다.", "compat_jump": "{{line}}번째 줄로 이동", "compat_docs": "호환성 문서", "compat_count": "적용되지 않는 항목 {{count}}개", "compat_scriptcat_only": "ScriptCat 전용", "perm_other_label": "기타 선언", - "perm_other_summary": "선언되었지만 ScriptCat이 실행하지 않습니다" + "perm_other_summary": "다른 스크립트 관리자와 호환성이 다릅니다" } diff --git a/src/locales/pt-BR/install.json b/src/locales/pt-BR/install.json index 244237720..701afd004 100644 --- a/src/locales/pt-BR/install.json +++ b/src/locales/pt-BR/install.json @@ -257,11 +257,12 @@ "code_loading": "Carregando código", "compat_ineffective": "sem efeito", "compat_tag_desc": "O ScriptCat não oferece suporte a esta declaração; ela é ignorada após a instalação.", + "compat_value_desc": "O ScriptCat não oferece suporte a este valor; a declaração não terá o efeito escrito.", "compat_grant_desc": "O ScriptCat não implementou esta API; as chamadas falham e os recursos que dependem dela não funcionam.", "compat_jump": "Ir para a linha {{line}}", "compat_docs": "Documentação de compatibilidade", "compat_count": "{{count}} sem efeito", "compat_scriptcat_only": "Somente ScriptCat", "perm_other_label": "Outras declarações", - "perm_other_summary": "Declarado pelo script, mas não executado pelo ScriptCat" + "perm_other_summary": "Comportamento diferente de outros gerenciadores de scripts" } diff --git a/src/locales/ru-RU/install.json b/src/locales/ru-RU/install.json index 0ab5e9cf3..0630ebbc0 100644 --- a/src/locales/ru-RU/install.json +++ b/src/locales/ru-RU/install.json @@ -257,11 +257,12 @@ "code_loading": "Загрузка кода", "compat_ineffective": "не действует", "compat_tag_desc": "ScriptCat не поддерживает это объявление — после установки оно игнорируется.", + "compat_value_desc": "ScriptCat не поддерживает это значение — объявление не сработает так, как написано.", "compat_grant_desc": "ScriptCat не реализует этот API: вызов из скрипта завершится ошибкой, а зависящие от него функции работать не будут.", "compat_jump": "Перейти к строке {{line}}", "compat_docs": "Документация по совместимости", "compat_count": "Не действует: {{count}}", "compat_scriptcat_only": "только ScriptCat", "perm_other_label": "Прочие объявления", - "perm_other_summary": "Объявлено скриптом, но ScriptCat это не выполняет" + "perm_other_summary": "Работает не так, как в других менеджерах скриптов" } diff --git a/src/locales/tr-TR/install.json b/src/locales/tr-TR/install.json index e32f3b089..1df386389 100644 --- a/src/locales/tr-TR/install.json +++ b/src/locales/tr-TR/install.json @@ -257,11 +257,12 @@ "code_loading": "Kod yükleniyor", "compat_ineffective": "etkisiz", "compat_tag_desc": "ScriptCat bu bildirimi desteklemiyor; kurulumdan sonra yok sayılır.", + "compat_value_desc": "ScriptCat bu değeri desteklemiyor; bildirim yazıldığı gibi uygulanmaz.", "compat_grant_desc": "ScriptCat bu API'yi uygulamadı; betik çağırdığında hata verir ve buna dayanan özellikler çalışmaz.", "compat_jump": "{{line}}. satıra git", "compat_docs": "Uyumluluk belgeleri", "compat_count": "{{count}} etkisiz", "compat_scriptcat_only": "Yalnızca ScriptCat", "perm_other_label": "Diğer bildirimler", - "perm_other_summary": "Betikte bildirildi ancak ScriptCat tarafından çalıştırılmıyor" + "perm_other_summary": "Diğer betik yöneticilerinden farklı davranır" } diff --git a/src/locales/vi-VN/install.json b/src/locales/vi-VN/install.json index 3ee2d270a..05763b087 100644 --- a/src/locales/vi-VN/install.json +++ b/src/locales/vi-VN/install.json @@ -257,11 +257,12 @@ "code_loading": "Đang tải mã", "compat_ineffective": "không có tác dụng", "compat_tag_desc": "ScriptCat không hỗ trợ khai báo này; nó sẽ bị bỏ qua sau khi cài đặt.", + "compat_value_desc": "ScriptCat không hỗ trợ giá trị này; khai báo sẽ không có hiệu lực như đã viết.", "compat_grant_desc": "ScriptCat chưa triển khai API này; script gọi đến sẽ báo lỗi và các tính năng phụ thuộc vào nó sẽ không hoạt động.", "compat_jump": "Đến dòng {{line}}", "compat_docs": "Tài liệu tương thích", "compat_count": "{{count}} mục không có tác dụng", "compat_scriptcat_only": "Chỉ ScriptCat", "perm_other_label": "Khai báo khác", - "perm_other_summary": "Script có khai báo nhưng ScriptCat không thực thi" + "perm_other_summary": "Hoạt động khác với các trình quản lý script khác" } diff --git a/src/locales/zh-CN/install.json b/src/locales/zh-CN/install.json index 105686d56..40d0df599 100644 --- a/src/locales/zh-CN/install.json +++ b/src/locales/zh-CN/install.json @@ -257,11 +257,12 @@ "code_loading": "正在加载代码", "compat_ineffective": "不生效", "compat_tag_desc": "脚本猫不支持该声明,安装后会被忽略。", + "compat_value_desc": "脚本猫不支持这个取值,该声明不会按写法生效。", "compat_grant_desc": "脚本猫未实现该 API,脚本调用时会报错,依赖它的功能不可用。", "compat_jump": "跳到第 {{line}} 行", "compat_docs": "兼容性文档", "compat_count": "{{count}} 项不生效", "compat_scriptcat_only": "仅限脚本猫", "perm_other_label": "其他声明", - "perm_other_summary": "已声明,但脚本猫不会执行" + "perm_other_summary": "兼容性与其他脚本管理器不同" } diff --git a/src/locales/zh-TW/install.json b/src/locales/zh-TW/install.json index 459d401e1..4c3e414c6 100644 --- a/src/locales/zh-TW/install.json +++ b/src/locales/zh-TW/install.json @@ -257,11 +257,12 @@ "code_loading": "正在載入程式碼", "compat_ineffective": "不生效", "compat_tag_desc": "腳本貓不支援此宣告,安裝後會被忽略。", + "compat_value_desc": "腳本貓不支援此取值,這項宣告不會照寫法生效。", "compat_grant_desc": "腳本貓尚未實作此 API,腳本呼叫時會出錯,依賴它的功能無法使用。", "compat_jump": "跳至第 {{line}} 行", "compat_docs": "相容性文件", "compat_count": "{{count}} 項不生效", "compat_scriptcat_only": "僅限腳本貓", "perm_other_label": "其他宣告", - "perm_other_summary": "已宣告,但腳本貓不會執行" + "perm_other_summary": "相容性與其他腳本管理器不同" } diff --git a/src/pages/install/App.test.tsx b/src/pages/install/App.test.tsx index aa3413d04..a22ee5be7 100644 --- a/src/pages/install/App.test.tsx +++ b/src/pages/install/App.test.tsx @@ -42,7 +42,7 @@ const readyView = (over: Partial = {}): InstallView => ({ schedule: null, code: "// a\n// b", subscribeScripts: [], - compat: { grants: new Map(), tags: [] }, + compat: { grants: new Map(), tags: [], scriptcatOnlyTags: [] }, ...over, }); @@ -399,7 +399,7 @@ describe("安装页的不生效标记", () => { view: readyView({ permissions: [{ kind: "grant", risk: "warn", values: ["GM_setValue", "GM_audio"], sensitive: [] }], code: "// ==UserScript==\n// @name X\n// @grant GM_audio\n// ==/UserScript==", - compat: { grants: new Map([["GM_audio", 3]]), tags: [] }, + compat: { grants: new Map([["GM_audio", 3]]), tags: [], scriptcatOnlyTags: [] }, }), }, }); diff --git a/src/pages/install/compat.test.ts b/src/pages/install/compat.test.ts index a4dec8bed..d47592301 100644 --- a/src/pages/install/compat.test.ts +++ b/src/pages/install/compat.test.ts @@ -16,7 +16,7 @@ const build = (header: string) => { describe("安装页兼容性标记", () => { it("全部受支持时不产生任何标记", () => { const { code, metadata } = build(`// @name X\n// @match *://a.com/*\n// @grant GM_setValue\n`); - expect(deriveCompatMarks(metadata, code)).toEqual({ grants: new Map(), tags: [] }); + expect(deriveCompatMarks(metadata, code)).toEqual({ grants: new Map(), tags: [], scriptcatOnlyTags: [] }); }); it("标出脚本猫未实现的 @grant,并给出所在行", () => { @@ -59,4 +59,39 @@ describe("安装页兼容性标记", () => { const { code, metadata } = build(`// @name X\n// @top-level-await\n// @sandbox raw\n`); expect(deriveCompatMarks(metadata, code).tags.map((t) => t.tag)).toEqual(["top-level-await", "sandbox"]); }); + + it("受支持的指令写了不认得的取值,连同取值一起标出并定位到那一行", () => { + const { code, metadata } = build(`// @name X\n// @run-at document-weird\n// @inject-into auto\n`); + expect(deriveCompatMarks(metadata, code).tags).toEqual([ + { tag: "run-at", value: "document-weird", line: 3 }, + { tag: "inject-into", value: "auto", line: 4 }, + ]); + }); + + it("只读第一个取值的指令,后续取值定位到各自所在的行", () => { + const { code, metadata } = build(`// @name X\n// @run-in normal-tabs\n// @run-in incognito-tabs\n`); + expect(deriveCompatMarks(metadata, code).tags).toEqual([{ tag: "run-in", value: "incognito-tabs", line: 4 }]); + }); + + it("标出脚本猫独有的指令,按代码顺序给出行号", () => { + const { code, metadata } = build(`// @name X\n// @background\n// @run-at document-start\n// @early-start\n`); + const marks = deriveCompatMarks(metadata, code); + expect(marks.scriptcatOnlyTags).toEqual([ + { tag: "background", line: 3 }, + { tag: "early-start", line: 5 }, + ]); + expect(marks.tags).toEqual([]); + }); + + it("解析不出规则的 @match 标出取值与行号", () => { + const { code, metadata } = build(`// @name X\n// @match *://a.com/*\n// @match hello-world^^\n`); + expect(deriveCompatMarks(metadata, code).tags).toEqual([{ tag: "match", value: "hello-world^^", line: 4 }]); + }); + + it("脚本猫独有的指令写了也不生效时只按不生效标一次", () => { + const { code, metadata } = build(`// @name X\n// @early-start\n`); + const marks = deriveCompatMarks(metadata, code); + expect(marks.tags).toEqual([{ tag: "early-start", value: "", line: 3 }]); + expect(marks.scriptcatOnlyTags).toEqual([]); + }); }); diff --git a/src/pages/install/compat.ts b/src/pages/install/compat.ts index 46a5bb436..eecd56c96 100644 --- a/src/pages/install/compat.ts +++ b/src/pages/install/compat.ts @@ -1,9 +1,22 @@ import type { SCMetadata } from "@App/app/repo/metadata"; -import { parseMetadataLines } from "@App/pkg/utils/script"; -import { isSupportedGrant, isSupportedMetadataTag, resolveMetadataTagBase } from "@App/pkg/utils/script_compat"; +import { parseMetadataLines, type MetadataLine } from "@App/pkg/utils/script"; +import { + SCRIPTCAT_ONLY_METADATA_TAGS, + ineffectiveMetadataValues, + isSupportedGrant, + isSupportedMetadataTag, + resolveMetadataTagBase, +} from "@App/pkg/utils/script_compat"; export interface IneffectiveTag { /** 小写归一后的指令名,不含 @ */ + tag: string; + /** 指令本身受支持、只是取值不生效时给出该取值 */ + value?: string; + line: number | undefined; +} + +export interface ScriptCatOnlyTag { tag: string; line: number | undefined; } @@ -13,6 +26,8 @@ export interface CompatMarks { grants: Map; /** 不生效的元数据指令;权限卡里没有对应 chip,统一落在「其他声明」行 */ tags: IneffectiveTag[]; + /** 脚本猫独有的指令;在脚本猫里生效,换到别的管理器不生效 */ + scriptcatOnlyTags: ScriptCatOnlyTag[]; } /** 传给权限行的兼容性标记与跳转入口 */ @@ -32,33 +47,46 @@ export const compatMarkCount = (marks: CompatMarks): number => marks.grants.size */ export function deriveCompatMarks(metadata: SCMetadata, code: string): CompatMarks { const lines = parseMetadataLines(code); - const firstLineOf = new Map(); - for (const { tag, value, line } of lines) { - const tagKey = `@${tag}`; - if (!firstLineOf.has(tagKey)) firstLineOf.set(tagKey, line); - // @grant 按取值定位,同一指令的不同能力各自成行 - if (tag === "grant") { - const grantKey = `grant:${value}`; - if (!firstLineOf.has(grantKey)) firstLineOf.set(grantKey, line); - } + // 同一指令的第 i 个取值 → 所在行;parseMetadata 按出现顺序聚合取值,下标一一对应。 + // metadata 与代码不同源时取值会对不上,此时宁可不给行号也不跳错行 + const tagLines = new Map(); + for (const entry of lines) { + const list = tagLines.get(entry.tag); + if (list) list.push(entry); + else tagLines.set(entry.tag, [entry]); } + const lineOf = (tag: string, index = 0, value?: string) => { + const entry = tagLines.get(tag)?.[index]; + return entry && (value === undefined || entry.value === value) ? entry.line : undefined; + }; const grants = new Map(); - for (const grant of metadata.grant || []) { - if (grant === "none" || isSupportedGrant(grant) || grants.has(grant)) continue; - grants.set(grant, firstLineOf.get(`grant:${grant}`)); - } + (metadata.grant || []).forEach((grant, index) => { + if (grant === "none" || isSupportedGrant(grant) || grants.has(grant)) return; + grants.set(grant, lineOf("grant", index, grant)); + }); + + const byLine = (a: { line: number | undefined }, b: { line: number | undefined }) => + (a.line ?? Infinity) - (b.line ?? Infinity); - const seen = new Set(); + const ineffectiveValues = ineffectiveMetadataValues(metadata); + // 独有指令在这份脚本里本身不生效时,读者要知道的是「不生效」,不再重复标「仅限脚本猫」 + const seen = new Set(ineffectiveValues.map((v) => v.tag).filter((tag) => SCRIPTCAT_ONLY_METADATA_TAGS.has(tag))); const tags: IneffectiveTag[] = []; + const scriptcatOnlyTags: ScriptCatOnlyTag[] = []; // 以代码出现顺序为准;metadata 是对象,键序不表达脚本里的书写顺序 const ordered = [...lines.map((l) => l.tag), ...Object.keys(metadata)]; for (const rawTag of ordered) { const tag = resolveMetadataTagBase(rawTag); - if (seen.has(tag) || isSupportedMetadataTag(tag)) continue; + if (seen.has(tag)) continue; seen.add(tag); - tags.push({ tag, line: firstLineOf.get(`@${tag}`) }); + if (!isSupportedMetadataTag(tag)) tags.push({ tag, line: lineOf(tag) }); + else if (SCRIPTCAT_ONLY_METADATA_TAGS.has(tag)) scriptcatOnlyTags.push({ tag, line: lineOf(tag) }); + } + for (const { tag, index, value } of ineffectiveValues) { + tags.push({ tag, value, line: lineOf(tag, index, value) }); } + tags.sort(byLine); - return { grants, tags }; + return { grants, tags, scriptcatOnlyTags }; } diff --git a/src/pages/install/components/CompatChip.test.tsx b/src/pages/install/components/CompatChip.test.tsx index 6da8e8b30..6e2b4c236 100644 --- a/src/pages/install/components/CompatChip.test.tsx +++ b/src/pages/install/components/CompatChip.test.tsx @@ -26,6 +26,12 @@ describe("CompatChip 不生效标记", () => { expect(screen.getByText("脚本猫不支持该声明,安装后会被忽略。")).toBeInTheDocument(); }); + it("鼠标移入弹出说明:不认得的取值说清声明不会按写法生效", () => { + render(); + fireEvent.mouseEnter(screen.getByTestId("compat-chip")); + expect(screen.getByText("脚本猫不支持这个取值,该声明不会按写法生效。")).toBeInTheDocument(); + }); + it("移出后收起说明", async () => { vi.useFakeTimers(); try { diff --git a/src/pages/install/components/CompatChip.tsx b/src/pages/install/components/CompatChip.tsx index 40fba35ef..382d937f7 100644 --- a/src/pages/install/components/CompatChip.tsx +++ b/src/pages/install/components/CompatChip.tsx @@ -6,7 +6,7 @@ import { localePath } from "@App/locales/locales"; import { Popover, PopoverAnchor, PopoverContent } from "@App/pages/components/ui/popover"; import { useHoverMenu } from "@App/pages/components/ui/use-hover-menu"; -export type CompatChipKind = "metadata" | "grant"; +export type CompatChipKind = "metadata" | "value" | "grant"; // 同一时刻只开一枚浮层:标记 chip 常常并排,浮层有 288px 宽,两枚同时开会互相盖住。 // 悬停切换时旧浮层要等关闭延迟才收,靠这里立刻收掉。 @@ -14,11 +14,13 @@ let closeActivePopover: (() => void) | null = null; const DOC_PATH: Record = { metadata: "/docs/dev/meta", + value: "/docs/dev/meta", grant: "/docs/dev/api", }; const DESC_KEY: Record = { metadata: "install:compat_tag_desc", + value: "install:compat_value_desc", grant: "install:compat_grant_desc", }; @@ -104,3 +106,16 @@ export function CompatChip({ ); } + +/** 在脚本猫里生效、换到别的脚本管理器就不生效的指令或 API */ +export function ScriptCatOnlyBadge() { + const { t } = useTranslation(["install", "common"]); + return ( + + {t("install:compat_scriptcat_only")} + + ); +} diff --git a/src/pages/install/components/PermissionCard.test.tsx b/src/pages/install/components/PermissionCard.test.tsx index c1e4d7d09..a8bc562f9 100644 --- a/src/pages/install/components/PermissionCard.test.tsx +++ b/src/pages/install/components/PermissionCard.test.tsx @@ -209,7 +209,9 @@ describe("PermissionCard 上的不生效标记", () => { render( ); expect(screen.getByText("2 项不生效")).toBeInTheDocument(); @@ -226,6 +228,7 @@ describe("PermissionCard 上的不生效标记", () => { { tag: "exclude-match", line: 3 }, { tag: "sandbox", line: 4 }, ], + scriptcatOnlyTags: [], }, }} /> @@ -241,12 +244,48 @@ describe("PermissionCard 上的不生效标记", () => { }); it("没有不生效项时既无徽章也无其他声明行——全兼容的安装页一字不改", () => { - render(); + render(); expect(screen.queryByTestId("permission-row-other")).not.toBeInTheDocument(); expect(screen.queryByText(/项不生效/)).not.toBeInTheDocument(); }); }); +describe("PermissionCard 其他声明行的取值与脚本猫独有指令", () => { + const rows: PermissionRow[] = [{ kind: "match", risk: "normal", values: ["*://a.com/*"], sensitive: [] }]; + + it("不认得的取值连同取值一起标为不生效", () => { + render( + + ); + expect(within(screen.getByTestId("permission-row-other")).getByTestId("compat-chip")).toHaveTextContent( + "@run-at document-weird" + ); + }); + + it("脚本猫独有的指令带仅限脚本猫标签,不算进不生效计数", () => { + render( + + ); + const row = screen.getByTestId("permission-row-other"); + const chip = within(row).getByText("@early-start").closest("[data-chip]")!; + expect(within(chip as HTMLElement).getByTestId("scriptcat-only")).toHaveTextContent("仅限脚本猫"); + expect(within(row).queryByTestId("compat-chip")).not.toBeInTheDocument(); + expect(screen.queryByTestId("permission-card-compat")).not.toBeInTheDocument(); + }); +}); + describe("不生效标记与折叠形态的关系", () => { const rows: PermissionRow[] = [ { kind: "grant", risk: "warn", values: ["GM_audio"], sensitive: [], diff: { added: [], removed: [] } }, @@ -257,7 +296,7 @@ describe("不生效标记与折叠形态的关系", () => { ); expect(screen.queryByTestId("permission-card-collapsed")).not.toBeInTheDocument(); @@ -265,7 +304,13 @@ describe("不生效标记与折叠形态的关系", () => { }); it("没有不生效项时仍按原样塌成单行", () => { - render(); + render( + + ); expect(screen.getByTestId("permission-card-collapsed")).toBeInTheDocument(); }); }); @@ -279,7 +324,7 @@ describe("移动端的不生效标记", () => { { kind: "match", risk: "normal", values: ["*://a.com/*"], sensitive: [] }, { kind: "grant", risk: "warn", values: ["GM_audio"], sensitive: [] }, ]} - compat={{ marks: { grants: new Map([["GM_audio", 9]]), tags: [] } }} + compat={{ marks: { grants: new Map([["GM_audio", 9]]), tags: [], scriptcatOnlyTags: [] } }} /> ); mobile = false; diff --git a/src/pages/install/components/PermissionCard.tsx b/src/pages/install/components/PermissionCard.tsx index 60d62464d..4880dea73 100644 --- a/src/pages/install/components/PermissionCard.tsx +++ b/src/pages/install/components/PermissionCard.tsx @@ -5,7 +5,7 @@ import { cn } from "@App/pkg/utils/cn"; import { useIsMobile } from "@App/pages/components/use-is-mobile"; import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from "@App/pages/components/ui/accordion"; import { compatMarkCount, type CompatView } from "../compat"; -import { CompatChip } from "./CompatChip"; +import { CompatChip, ScriptCatOnlyBadge } from "./CompatChip"; import { isPermissionChanged, type PermissionRow as PermissionRowData } from "../permissions"; import { PermissionRow, PermissionChips, PermissionDelta, NoChangeTag, KIND_META, RISK_STYLE } from "./PermissionRow"; @@ -121,13 +121,14 @@ function CollapsedCard({ } /** - * 不生效的元数据指令(@exclude-match、@sandbox 等)。 + * 与别家脚本管理器行为不同的元数据声明:脚本猫不会执行的指令或取值(@exclude-match、@run-at document-weird), + * 以及只有脚本猫认的指令(@early-start、@background)。 * 它们不是权限,但同样是「脚本写了、脚本猫不会执行」,与权限行同列才对得起读者的一次扫视。 */ function OtherDirectivesRow({ compat }: { compat: CompatView }) { const { t } = useTranslation(["install", "common"]); - const { tags } = compat.marks; - if (!tags.length) return null; + const { tags, scriptcatOnlyTags } = compat.marks; + if (!tags.length && !scriptcatOnlyTags.length) return null; return (
@@ -141,7 +142,23 @@ function OtherDirectivesRow({ compat }: { compat: CompatView }) {
{tags.map((tag) => ( - + + ))} + {scriptcatOnlyTags.map((tag) => ( + + {`@${tag.tag}`} + + ))}
diff --git a/src/pages/install/components/PermissionRow.test.tsx b/src/pages/install/components/PermissionRow.test.tsx index 90695a61c..143b328e0 100644 --- a/src/pages/install/components/PermissionRow.test.tsx +++ b/src/pages/install/components/PermissionRow.test.tsx @@ -153,7 +153,7 @@ describe("PermissionRow 零变动行的取值折叠", () => { describe("PermissionRow 上的不生效标记", () => { const compat = (over: Partial<{ grants: Map; tags: IneffectiveTag[] }> = {}) => ({ - marks: { grants: new Map(), tags: [], ...over }, + marks: { grants: new Map(), tags: [], scriptcatOnlyTags: [], ...over }, }); it("不受支持的 GM 能力就地换成不生效标记,其余 chip 不变", () => { diff --git a/src/pages/install/components/PermissionRow.tsx b/src/pages/install/components/PermissionRow.tsx index 642acc8e7..4377e9833 100644 --- a/src/pages/install/components/PermissionRow.tsx +++ b/src/pages/install/components/PermissionRow.tsx @@ -4,7 +4,7 @@ import { Globe, ArrowLeftRight, ChevronDown, KeyRound, Package, TriangleAlert, t import { cn } from "@App/pkg/utils/cn"; import { isScriptCatOnlyGrant } from "@App/pkg/utils/script_compat"; import type { CompatView } from "../compat"; -import { CompatChip } from "./CompatChip"; +import { CompatChip, ScriptCatOnlyBadge } from "./CompatChip"; import { isPermissionChanged, type PermissionKind, @@ -81,14 +81,7 @@ function Chip({ value, row, change }: { value: string; row: PermissionRowData; c {isSensitive && change !== "removed" && } {change && {t(CHANGE_LABEL_KEY[change])}} {value} - {isScriptCatOnly && ( - - {t("install:compat_scriptcat_only")} - - )} + {isScriptCatOnly && } ); } diff --git a/src/pages/install/useInstallData.test.ts b/src/pages/install/useInstallData.test.ts index d39635cea..d9619b3cb 100644 --- a/src/pages/install/useInstallData.test.ts +++ b/src/pages/install/useInstallData.test.ts @@ -133,6 +133,7 @@ describe("assembleInstallView 组装安装视图", () => { }); expect(view.compat.grants).toEqual(new Map([["GM_audio", 4]])); expect(view.compat.tags).toEqual([{ tag: "exclude-match", line: 3 }]); + expect(view.compat.scriptcatOnlyTags).toEqual([]); }); it("全新安装组装名称、来源、版本与权限", () => { diff --git a/src/pkg/utils/script_compat.test.ts b/src/pkg/utils/script_compat.test.ts index 733f96282..c2a6a0761 100644 --- a/src/pkg/utils/script_compat.test.ts +++ b/src/pkg/utils/script_compat.test.ts @@ -2,12 +2,17 @@ import { describe, it, expect } from "vitest"; import "@App/app/service/content/gm_api/gm_api"; import { GMContextApiNames } from "@App/app/service/content/gm_api/gm_context"; import { compatMap as eslintHeaderCompatMap } from "@Packages/eslint/compat-headers"; +import { compatMap as upstreamHeaderCompatMap } from "eslint-plugin-userscripts/dist/data/compat-headers.js"; import { + CONSUMED_METADATA_TAGS, CONTEXT_PROVIDED_GRANTS, + SCRIPTCAT_ONLY_METADATA_TAGS, + VALUE_CONSTRAINED_TAGS, SUPPORTED_GRANTS, SUPPORTED_METADATA_TAGS, SYNTHETIC_METADATA_TAGS, isSupportedGrant, + ineffectiveMetadataValues, isScriptCatOnlyGrant, isSupportedMetadataTag, resolveMetadataTagBase, @@ -84,6 +89,98 @@ describe("GM 能力支持判定", () => { }); }); +describe("元数据取值支持判定", () => { + it("取值在脚本猫认得的范围内时不报", () => { + expect( + ineffectiveMetadataValues({ + "run-at": ["document-start"], + "run-in": ["incognito-tabs"], + "inject-into": ["content"], + "early-start": [""], + unwrap: ["true"], + }) + ).toEqual([]); + for (const runAt of ["document-body", "document-end", "document-idle", "context-menu"]) { + expect(ineffectiveMetadataValues({ "run-at": [runAt] }), runAt).toEqual([]); + } + }); + + it("取值不在白名单内一律报出,不需要事先登记——运行时对不认识的取值会静默回退", () => { + expect( + ineffectiveMetadataValues({ + "run-at": ["document-weird"], + "run-in": ["container-id-2"], + "inject-into": ["auto"], + unwrap: ["yes"], + }) + ).toEqual([ + { tag: "run-at", index: 0, value: "document-weird" }, + { tag: "run-in", index: 0, value: "container-id-2" }, + { tag: "inject-into", index: 0, value: "auto" }, + { tag: "unwrap", index: 0, value: "yes" }, + ]); + }); + + it("取值大小写敏感,与运行时的比较方式一致", () => { + expect(ineffectiveMetadataValues({ "run-at": ["Document-Start"] })).toEqual([ + { tag: "run-at", index: 0, value: "Document-Start" }, + ]); + }); + + it("运行时只读第一个取值的指令,后续取值报为不生效", () => { + expect(ineffectiveMetadataValues({ "run-in": ["normal-tabs", "incognito-tabs"] })).toEqual([ + { tag: "run-in", index: 1, value: "incognito-tabs" }, + ]); + }); + + it("@early-start 只在 @run-at document-start 下生效", () => { + expect(ineffectiveMetadataValues({ "early-start": [""] })).toEqual([{ tag: "early-start", index: 0, value: "" }]); + expect(ineffectiveMetadataValues({ "early-start": [""], "run-at": ["document-start"] })).toEqual([]); + }); + + it("运行时解析不出匹配规则的 @match 报为不生效,能解析的(含兼容 TM 的简写)不报", () => { + expect( + ineffectiveMetadataValues({ + match: ["*://a.com/*", "www.youtube.com/*", "*", "hello-world^^", ""], + }) + ).toEqual([ + { tag: "match", index: 3, value: "hello-world^^" }, + { tag: "match", index: 4, value: "" }, + ]); + }); + + it("不限取值的指令不做取值判定", () => { + expect(ineffectiveMetadataValues({ include: ["anything"], namespace: ["x"], noframes: ["whatever"] })).toEqual([]); + }); + + it("有取值约束的指令都是脚本猫会消费的指令", () => { + expect(VALUE_CONSTRAINED_TAGS.filter((tag) => !CONSUMED_METADATA_TAGS.has(tag))).toEqual([]); + }); +}); + +describe("仅限脚本猫的元数据指令", () => { + // 只对照会被消费的指令:信息类(如 @definition)在脚本猫里同样不起作用,标「仅限脚本猫」会误导 + it("与 eslint-plugin-userscripts 收录的别家指令对照:脚本猫会消费、别家都没有的,就是脚本猫独有的", () => { + const upstream = new Set( + [ + ...Object.keys(upstreamHeaderCompatMap.unlocalized), + ...Object.keys(upstreamHeaderCompatMap.nonFunctional), + ...Object.keys(upstreamHeaderCompatMap.localized), + ].map((key) => key.toLowerCase()) + ); + const expected = [...CONSUMED_METADATA_TAGS].filter( + (tag) => !upstream.has(tag) && !SYNTHETIC_METADATA_TAGS.has(tag) + ); + expect([...SCRIPTCAT_ONLY_METADATA_TAGS].sort()).toEqual(expected.sort()); + }); + + it("包含 @early-start 与 @background,不包含通用指令", () => { + expect(SCRIPTCAT_ONLY_METADATA_TAGS.has("early-start")).toBe(true); + expect(SCRIPTCAT_ONLY_METADATA_TAGS.has("background")).toBe(true); + expect(SCRIPTCAT_ONLY_METADATA_TAGS.has("match")).toBe(false); + }); +}); + describe("仅限脚本猫的 GM 能力判定", () => { it("CAT_ 与 CAT. 命名空间下的已实现能力仅限脚本猫", () => { for (const grant of ["CAT_fileStorage", "CAT_userConfig", "CAT.agent.dom"]) { diff --git a/src/pkg/utils/script_compat.ts b/src/pkg/utils/script_compat.ts index 069937d38..1a164b57f 100644 --- a/src/pkg/utils/script_compat.ts +++ b/src/pkg/utils/script_compat.ts @@ -1,4 +1,6 @@ +import type { SCMetadata } from "@App/app/repo/metadata"; import { getGrantCandidates } from "@App/app/service/content/gm_api/grant"; +import { extractUrlPatterns } from "./url_matcher"; /** * 脚本猫的兼容性支持表:安装页与编辑器共用的唯一判定来源。 @@ -6,6 +8,7 @@ import { getGrantCandidates } from "@App/app/service/content/gm_api/grant"; * 判定是二元的——指令/能力要么被脚本猫消费,要么写了也不会生效,没有中间档。 * 表外即「不生效」,因此收录标准是「脚本猫会消费它」或「脚本猫不消费但它也不改变脚本运行行为」; * 只有会改变别家管理器下脚本行为、而脚本猫没实现的指令才刻意留在表外(如 @exclude-match)。 + * 取值同理:取值有限的指令只认白名单里的取值,运行时对不认得的取值会静默回退。 */ // name/description/antifeature 可带 `:` 后缀取本地化值(src/locales/locales.ts), @@ -107,11 +110,69 @@ const INFORMATIONAL_TAGS = [ // 订阅脚本的 metadata 里会出现这个键,必须视为支持,否则安装页会把它标成不生效。 export const SYNTHETIC_METADATA_TAGS: ReadonlySet = new Set(["usersubscribe"]); +export const CONSUMED_METADATA_TAGS: ReadonlySet = new Set(CONSUMED_TAGS); + export const SUPPORTED_METADATA_TAGS: ReadonlySet = new Set([...CONSUMED_TAGS, ...INFORMATIONAL_TAGS]); export const isSupportedMetadataTag = (tag: string): boolean => SUPPORTED_METADATA_TAGS.has(resolveMetadataTagBase(tag)); +// 脚本猫独有、别家管理器不认的指令。script_compat.test.ts 对照 eslint-plugin-userscripts 收录的别家指令守卫 +export const SCRIPTCAT_ONLY_METADATA_TAGS: ReadonlySet = new Set([ + "require-css", + "early-start", + "background", + "crontab", + "storagename", + "cloudcat", + "cloudserver", + "exportvalue", + "exportcookie", + "scripturl", +]); + +// 运行时按 metadata[tag][0] 与这些取值逐字比较,其余取值(含大小写不同)都会回退到默认行为: +// run-at → getRunAt / isContextMenuScript / script_executor;run-in → runtime.ts; +// inject-into → isInjectIntoContent,page 即默认行为;unwrap / early-start → metadataBlankOrTrue。 +// false 与不写的效果一致,写出来也符合作者本意,不算不生效。 +const BLANK_OR_BOOLEAN = new Set(["", "true", "false"]); +const SUPPORTED_TAG_VALUES: Readonly>> = { + "run-at": new Set(["document-start", "document-body", "document-end", "document-idle", "context-menu"]), + "run-in": new Set(["all", "normal-tabs", "incognito-tabs"]), + "inject-into": new Set(["page", "content"]), + unwrap: BLANK_OR_BOOLEAN, + "early-start": BLANK_OR_BOOLEAN, +}; + +export const VALUE_CONSTRAINED_TAGS: readonly string[] = Object.keys(SUPPORTED_TAG_VALUES); + +export interface IneffectiveMetadataValue { + tag: string; + /** 在 metadata[tag] 中的下标,用于回找代码行 */ + index: number; + value: string; +} + +/** 受支持指令里不会按写法生效的取值:不在白名单内、运行时只读第一个而被忽略的后续取值、解析不出规则的 @match */ +export function ineffectiveMetadataValues(metadata: SCMetadata): IneffectiveMetadataValue[] { + const result: IneffectiveMetadataValue[] = []; + for (const [tag, allowed] of Object.entries(SUPPORTED_TAG_VALUES)) { + (metadata[tag] || []).forEach((value, index) => { + const effective = + index === 0 && + allowed.has(value) && + // early-start 只在 document-start 下接管注入(isEarlyStartScript) + (tag !== "early-start" || metadata["run-at"]?.[0] === "document-start"); + if (!effective) result.push({ tag, index, value }); + }); + } + // @match 写法开放,交给运行时同一个解析器判:解析不出规则的会被静默丢弃(@include/@exclude 总能退化成 glob) + (metadata.match || []).forEach((value, index) => { + if (!extractUrlPatterns([`@match ${value}`]).length) result.push({ tag: "match", index, value }); + }); + return result; +} + // 不经 GMContext 注册表、由沙盒上下文直接提供或无需授权的能力: // unsafeWindow 与 GM_info 恒定注入(src/app/service/content/create_context.ts、exec_script.ts), // window.onurlchange 在 createContext 里单独接管,none 表示不请求任何 GM 能力。 diff --git a/src/types/eslint-plugin-userscripts.d.ts b/src/types/eslint-plugin-userscripts.d.ts new file mode 100644 index 000000000..5846c63f7 --- /dev/null +++ b/src/types/eslint-plugin-userscripts.d.ts @@ -0,0 +1,8 @@ +// 未经 packages/eslint 覆盖的上游数据,script_compat.test.ts 用它推出哪些指令是脚本猫独有的 +declare module "eslint-plugin-userscripts/dist/data/compat-headers.js" { + export const compatMap: { + localized: Record; + unlocalized: Record; + nonFunctional: Record; + }; +} From 75bb2feb5c5336630917de90b38fd3ba4d1c3a8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Wed, 16 Sep 2026 17:06:14 +0800 Subject: [PATCH 7/9] =?UTF-8?q?=E2=9C=A8=20=E5=AE=89=E8=A3=85=E9=A1=B5?= =?UTF-8?q?=E4=B8=8D=E7=94=9F=E6=95=88=E6=A0=87=E8=AE=B0=E6=81=A2=E5=A4=8D?= =?UTF-8?q?=E6=8C=89=E7=B1=BB=E5=BD=92=E8=A1=8C=EF=BC=8C=E6=96=87=E6=A1=A3?= =?UTF-8?q?=E9=93=BE=E6=8E=A5=E5=8F=AA=E6=8C=87=E5=90=91=E6=9C=89=E8=AF=B4?= =?UTF-8?q?=E6=98=8E=E7=9A=84=E5=B0=8F=E8=8A=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - @exclude-match 等已知影响运行网站的指令挂回运行网站行;归组只决定摆放, 支持与否仍由支持表判定,不认识的指令照样落在其他声明,不会漏标。 解析不出规则的 @match 在运行网站行就地换成不生效标记,不再重复成条。 - 浮层链接改为「描述文档」并带小节锚点,只给文档里确有说明的条目; 不支持的指令与 API 文档里本来没有,不再给一个点过去找不到的链接。 「仅限脚本猫」标签同样链到对应小节。 - 标记按作者的原始写法呈现(@storageName 而非 @storagename)。 - 移动端卡头的不生效计数不再折行。 --- src/locales/de-DE/install.json | 2 +- src/locales/en-US/install.json | 2 +- src/locales/ja-JP/install.json | 2 +- src/locales/ko-KR/install.json | 2 +- src/locales/pt-BR/install.json | 2 +- src/locales/ru-RU/install.json | 2 +- src/locales/tr-TR/install.json | 2 +- src/locales/vi-VN/install.json | 2 +- src/locales/zh-CN/install.json | 2 +- src/locales/zh-TW/install.json | 2 +- src/pages/install/App.test.tsx | 4 +- src/pages/install/compat.test.ts | 44 ++++--- src/pages/install/compat.ts | 47 ++++++-- src/pages/install/compat_docs.test.ts | 20 ++++ src/pages/install/compat_docs.ts | 34 ++++++ .../install/components/CompatChip.test.tsx | 29 ++++- src/pages/install/components/CompatChip.tsx | 55 +++++---- .../components/PermissionCard.test.tsx | 111 ++++++++++++++++-- .../install/components/PermissionCard.tsx | 23 ++-- .../install/components/PermissionRow.test.tsx | 57 ++++++++- .../install/components/PermissionRow.tsx | 42 ++++++- src/pages/install/useInstallData.test.ts | 2 +- src/pkg/utils/script.test.ts | 14 ++- src/pkg/utils/script.ts | 4 +- 24 files changed, 406 insertions(+), 100 deletions(-) create mode 100644 src/pages/install/compat_docs.test.ts create mode 100644 src/pages/install/compat_docs.ts diff --git a/src/locales/de-DE/install.json b/src/locales/de-DE/install.json index 594b3c4e3..b5015c48d 100644 --- a/src/locales/de-DE/install.json +++ b/src/locales/de-DE/install.json @@ -260,7 +260,7 @@ "compat_value_desc": "ScriptCat unterstützt diesen Wert nicht; die Deklaration wirkt nicht wie angegeben.", "compat_grant_desc": "ScriptCat implementiert diese API nicht; Aufrufe schlagen fehl und darauf aufbauende Funktionen arbeiten nicht.", "compat_jump": "Zu Zeile {{line}} springen", - "compat_docs": "Kompatibilitätsdokumentation", + "compat_docs": "Metadaten-Dokumentation", "compat_count": "{{count}} ohne Wirkung", "compat_scriptcat_only": "nur ScriptCat", "perm_other_label": "Weitere Deklarationen", diff --git a/src/locales/en-US/install.json b/src/locales/en-US/install.json index f2c235c5b..6a9de3a0b 100644 --- a/src/locales/en-US/install.json +++ b/src/locales/en-US/install.json @@ -260,7 +260,7 @@ "compat_value_desc": "ScriptCat does not support this value; the declaration won't take effect as written.", "compat_grant_desc": "ScriptCat has not implemented this API; calls to it fail and features relying on it won't work.", "compat_jump": "Go to line {{line}}", - "compat_docs": "Compatibility docs", + "compat_docs": "Metadata docs", "compat_count": "{{count}} with no effect", "compat_scriptcat_only": "ScriptCat only", "perm_other_label": "Other declarations", diff --git a/src/locales/ja-JP/install.json b/src/locales/ja-JP/install.json index 1d90f035c..20ebfda09 100644 --- a/src/locales/ja-JP/install.json +++ b/src/locales/ja-JP/install.json @@ -260,7 +260,7 @@ "compat_value_desc": "ScriptCat はこの値に対応していないため、宣言は記述どおりには機能しません。", "compat_grant_desc": "ScriptCat はこの API を実装していないため、スクリプトから呼び出すとエラーになり、これに依存する機能は動作しません。", "compat_jump": "{{line}} 行目へ移動", - "compat_docs": "互換性ドキュメント", + "compat_docs": "メタデータのドキュメント", "compat_count": "無効な項目 {{count}} 件", "compat_scriptcat_only": "ScriptCat 専用", "perm_other_label": "その他の宣言", diff --git a/src/locales/ko-KR/install.json b/src/locales/ko-KR/install.json index 8dd74d50b..2a6af77c0 100644 --- a/src/locales/ko-KR/install.json +++ b/src/locales/ko-KR/install.json @@ -260,7 +260,7 @@ "compat_value_desc": "ScriptCat은 이 값을 지원하지 않으므로 선언이 작성한 대로 적용되지 않습니다.", "compat_grant_desc": "ScriptCat은 이 API를 구현하지 않았습니다. 스크립트에서 호출하면 오류가 발생하고 이에 의존하는 기능은 동작하지 않습니다.", "compat_jump": "{{line}}번째 줄로 이동", - "compat_docs": "호환성 문서", + "compat_docs": "메타데이터 문서", "compat_count": "적용되지 않는 항목 {{count}}개", "compat_scriptcat_only": "ScriptCat 전용", "perm_other_label": "기타 선언", diff --git a/src/locales/pt-BR/install.json b/src/locales/pt-BR/install.json index 701afd004..e1973b3d3 100644 --- a/src/locales/pt-BR/install.json +++ b/src/locales/pt-BR/install.json @@ -260,7 +260,7 @@ "compat_value_desc": "O ScriptCat não oferece suporte a este valor; a declaração não terá o efeito escrito.", "compat_grant_desc": "O ScriptCat não implementou esta API; as chamadas falham e os recursos que dependem dela não funcionam.", "compat_jump": "Ir para a linha {{line}}", - "compat_docs": "Documentação de compatibilidade", + "compat_docs": "Documentação de metadados", "compat_count": "{{count}} sem efeito", "compat_scriptcat_only": "Somente ScriptCat", "perm_other_label": "Outras declarações", diff --git a/src/locales/ru-RU/install.json b/src/locales/ru-RU/install.json index 0630ebbc0..3cad830e6 100644 --- a/src/locales/ru-RU/install.json +++ b/src/locales/ru-RU/install.json @@ -260,7 +260,7 @@ "compat_value_desc": "ScriptCat не поддерживает это значение — объявление не сработает так, как написано.", "compat_grant_desc": "ScriptCat не реализует этот API: вызов из скрипта завершится ошибкой, а зависящие от него функции работать не будут.", "compat_jump": "Перейти к строке {{line}}", - "compat_docs": "Документация по совместимости", + "compat_docs": "Документация по метаданным", "compat_count": "Не действует: {{count}}", "compat_scriptcat_only": "только ScriptCat", "perm_other_label": "Прочие объявления", diff --git a/src/locales/tr-TR/install.json b/src/locales/tr-TR/install.json index 1df386389..02b227bd9 100644 --- a/src/locales/tr-TR/install.json +++ b/src/locales/tr-TR/install.json @@ -260,7 +260,7 @@ "compat_value_desc": "ScriptCat bu değeri desteklemiyor; bildirim yazıldığı gibi uygulanmaz.", "compat_grant_desc": "ScriptCat bu API'yi uygulamadı; betik çağırdığında hata verir ve buna dayanan özellikler çalışmaz.", "compat_jump": "{{line}}. satıra git", - "compat_docs": "Uyumluluk belgeleri", + "compat_docs": "Meta veri belgeleri", "compat_count": "{{count}} etkisiz", "compat_scriptcat_only": "Yalnızca ScriptCat", "perm_other_label": "Diğer bildirimler", diff --git a/src/locales/vi-VN/install.json b/src/locales/vi-VN/install.json index 05763b087..89067054a 100644 --- a/src/locales/vi-VN/install.json +++ b/src/locales/vi-VN/install.json @@ -260,7 +260,7 @@ "compat_value_desc": "ScriptCat không hỗ trợ giá trị này; khai báo sẽ không có hiệu lực như đã viết.", "compat_grant_desc": "ScriptCat chưa triển khai API này; script gọi đến sẽ báo lỗi và các tính năng phụ thuộc vào nó sẽ không hoạt động.", "compat_jump": "Đến dòng {{line}}", - "compat_docs": "Tài liệu tương thích", + "compat_docs": "Tài liệu metadata", "compat_count": "{{count}} mục không có tác dụng", "compat_scriptcat_only": "Chỉ ScriptCat", "perm_other_label": "Khai báo khác", diff --git a/src/locales/zh-CN/install.json b/src/locales/zh-CN/install.json index 40d0df599..974d93d78 100644 --- a/src/locales/zh-CN/install.json +++ b/src/locales/zh-CN/install.json @@ -260,7 +260,7 @@ "compat_value_desc": "脚本猫不支持这个取值,该声明不会按写法生效。", "compat_grant_desc": "脚本猫未实现该 API,脚本调用时会报错,依赖它的功能不可用。", "compat_jump": "跳到第 {{line}} 行", - "compat_docs": "兼容性文档", + "compat_docs": "描述文档", "compat_count": "{{count}} 项不生效", "compat_scriptcat_only": "仅限脚本猫", "perm_other_label": "其他声明", diff --git a/src/locales/zh-TW/install.json b/src/locales/zh-TW/install.json index 4c3e414c6..518c3a013 100644 --- a/src/locales/zh-TW/install.json +++ b/src/locales/zh-TW/install.json @@ -260,7 +260,7 @@ "compat_value_desc": "腳本貓不支援此取值,這項宣告不會照寫法生效。", "compat_grant_desc": "腳本貓尚未實作此 API,腳本呼叫時會出錯,依賴它的功能無法使用。", "compat_jump": "跳至第 {{line}} 行", - "compat_docs": "相容性文件", + "compat_docs": "描述文件", "compat_count": "{{count}} 項不生效", "compat_scriptcat_only": "僅限腳本貓", "perm_other_label": "其他宣告", diff --git a/src/pages/install/App.test.tsx b/src/pages/install/App.test.tsx index a22ee5be7..1ae789f3b 100644 --- a/src/pages/install/App.test.tsx +++ b/src/pages/install/App.test.tsx @@ -42,7 +42,7 @@ const readyView = (over: Partial = {}): InstallView => ({ schedule: null, code: "// a\n// b", subscribeScripts: [], - compat: { grants: new Map(), tags: [], scriptcatOnlyTags: [] }, + compat: { grants: new Map(), tags: [], matches: new Map(), scriptcatOnlyTags: [] }, ...over, }); @@ -399,7 +399,7 @@ describe("安装页的不生效标记", () => { view: readyView({ permissions: [{ kind: "grant", risk: "warn", values: ["GM_setValue", "GM_audio"], sensitive: [] }], code: "// ==UserScript==\n// @name X\n// @grant GM_audio\n// ==/UserScript==", - compat: { grants: new Map([["GM_audio", 3]]), tags: [], scriptcatOnlyTags: [] }, + compat: { grants: new Map([["GM_audio", 3]]), tags: [], matches: new Map(), scriptcatOnlyTags: [] }, }), }, }); diff --git a/src/pages/install/compat.test.ts b/src/pages/install/compat.test.ts index d47592301..9e7f3e973 100644 --- a/src/pages/install/compat.test.ts +++ b/src/pages/install/compat.test.ts @@ -16,7 +16,12 @@ const build = (header: string) => { describe("安装页兼容性标记", () => { it("全部受支持时不产生任何标记", () => { const { code, metadata } = build(`// @name X\n// @match *://a.com/*\n// @grant GM_setValue\n`); - expect(deriveCompatMarks(metadata, code)).toEqual({ grants: new Map(), tags: [], scriptcatOnlyTags: [] }); + expect(deriveCompatMarks(metadata, code)).toEqual({ + grants: new Map(), + matches: new Map(), + tags: [], + scriptcatOnlyTags: [], + }); }); it("标出脚本猫未实现的 @grant,并给出所在行", () => { @@ -30,28 +35,35 @@ describe("安装页兼容性标记", () => { expect(deriveCompatMarks(metadata, code).grants.size).toBe(0); }); - it("标出不生效的元数据指令,并给出所在行", () => { + it("已知影响运行网站的指令归到运行网站组,便于就地判断后果", () => { const { code, metadata } = build(`// @name X\n// @match *://a.com/*\n// @exclude-match *://b.com/*\n`); - expect(deriveCompatMarks(metadata, code).tags).toEqual([{ tag: "exclude-match", line: 4 }]); + expect(deriveCompatMarks(metadata, code).tags).toEqual([{ tag: "exclude-match", group: "match", line: 4 }]); }); - it("支持表之外的指令一律标出,不需要事先登记——别家以后新增的指令也不会漏", () => { + it("标记按作者的原始写法呈现,归组不受大小写影响", () => { + const { code, metadata } = build(`// @name X\n// @Exclude-Match *://b.com/*\n// @storageName s\n`); + const marks = deriveCompatMarks(metadata, code); + expect(marks.tags).toEqual([{ tag: "Exclude-Match", group: "match", line: 3 }]); + expect(marks.scriptcatOnlyTags).toEqual([{ tag: "storageName", line: 4 }]); + }); + + it("支持表之外的指令一律标出,不需要事先登记;不认识的落在其他组——别家以后新增的指令也不会漏", () => { const { code, metadata } = build(`// @name X\n// @match-website-only a.com\n// @sandbox raw\n`); expect(deriveCompatMarks(metadata, code).tags).toEqual([ - { tag: "match-website-only", line: 3 }, - { tag: "sandbox", line: 4 }, + { tag: "match-website-only", group: "other", line: 3 }, + { tag: "sandbox", group: "other", line: 4 }, ]); }); it("同一指令写了多行只标一枚,行号取第一次出现处", () => { const { code, metadata } = build(`// @name X\n// @sandbox a\n// @sandbox b\n`); - expect(deriveCompatMarks(metadata, code).tags).toEqual([{ tag: "sandbox", line: 3 }]); + expect(deriveCompatMarks(metadata, code).tags).toEqual([{ tag: "sandbox", group: "other", line: 3 }]); }); it("代码里定位不到时仍然成条,只是没有行号——诊断不能因为缺位置而消失", () => { const metadata: SCMetadata = { name: ["X"], sandbox: ["raw"], grant: ["GM_audio"] }; const marks = deriveCompatMarks(metadata, ""); - expect(marks.tags).toEqual([{ tag: "sandbox", line: undefined }]); + expect(marks.tags).toEqual([{ tag: "sandbox", group: "other", line: undefined }]); expect(marks.grants).toEqual(new Map([["GM_audio", undefined]])); }); @@ -63,14 +75,16 @@ describe("安装页兼容性标记", () => { it("受支持的指令写了不认得的取值,连同取值一起标出并定位到那一行", () => { const { code, metadata } = build(`// @name X\n// @run-at document-weird\n// @inject-into auto\n`); expect(deriveCompatMarks(metadata, code).tags).toEqual([ - { tag: "run-at", value: "document-weird", line: 3 }, - { tag: "inject-into", value: "auto", line: 4 }, + { tag: "run-at", value: "document-weird", group: "other", line: 3 }, + { tag: "inject-into", value: "auto", group: "other", line: 4 }, ]); }); it("只读第一个取值的指令,后续取值定位到各自所在的行", () => { const { code, metadata } = build(`// @name X\n// @run-in normal-tabs\n// @run-in incognito-tabs\n`); - expect(deriveCompatMarks(metadata, code).tags).toEqual([{ tag: "run-in", value: "incognito-tabs", line: 4 }]); + expect(deriveCompatMarks(metadata, code).tags).toEqual([ + { tag: "run-in", value: "incognito-tabs", group: "other", line: 4 }, + ]); }); it("标出脚本猫独有的指令,按代码顺序给出行号", () => { @@ -83,15 +97,17 @@ describe("安装页兼容性标记", () => { expect(marks.tags).toEqual([]); }); - it("解析不出规则的 @match 标出取值与行号", () => { + it("解析不出规则的 @match 按取值给出行号,交给运行网站行就地替换,不另成条", () => { const { code, metadata } = build(`// @name X\n// @match *://a.com/*\n// @match hello-world^^\n`); - expect(deriveCompatMarks(metadata, code).tags).toEqual([{ tag: "match", value: "hello-world^^", line: 4 }]); + const marks = deriveCompatMarks(metadata, code); + expect(marks.matches).toEqual(new Map([["hello-world^^", 4]])); + expect(marks.tags).toEqual([]); }); it("脚本猫独有的指令写了也不生效时只按不生效标一次", () => { const { code, metadata } = build(`// @name X\n// @early-start\n`); const marks = deriveCompatMarks(metadata, code); - expect(marks.tags).toEqual([{ tag: "early-start", value: "", line: 3 }]); + expect(marks.tags).toEqual([{ tag: "early-start", value: "", group: "other", line: 3 }]); expect(marks.scriptcatOnlyTags).toEqual([]); }); }); diff --git a/src/pages/install/compat.ts b/src/pages/install/compat.ts index eecd56c96..d7b3715ce 100644 --- a/src/pages/install/compat.ts +++ b/src/pages/install/compat.ts @@ -8,11 +8,15 @@ import { resolveMetadataTagBase, } from "@App/pkg/utils/script_compat"; +/** 不生效的指令挂到权限卡的哪一行呈现 */ +export type IneffectiveTagGroup = "match" | "other"; + export interface IneffectiveTag { - /** 小写归一后的指令名,不含 @ */ + /** 作者写的指令名,不含 @ */ tag: string; /** 指令本身受支持、只是取值不生效时给出该取值 */ value?: string; + group: IneffectiveTagGroup; line: number | undefined; } @@ -24,12 +28,21 @@ export interface ScriptCatOnlyTag { export interface CompatMarks { /** 不生效的 @grant → 所在行号;键与权限卡 GM 能力行的 chip 取值一致,直接按名字打标 */ grants: Map; - /** 不生效的元数据指令;权限卡里没有对应 chip,统一落在「其他声明」行 */ + /** 解析不出规则的 @match 取值 → 所在行号;运行网站行按取值就地打标 */ + matches: Map; + /** 不生效的元数据指令;权限卡里没有对应 chip,按 group 追加呈现 */ tags: IneffectiveTag[]; /** 脚本猫独有的指令;在脚本猫里生效,换到别的管理器不生效 */ scriptcatOnlyTags: ScriptCatOnlyTag[]; } +// 已知会影响「运行网站」的指令挂到那一行,读者才能就地判断后果。这里只决定摆放位置, +// 不决定支不支持(那由 script_compat.ts 的支持表判定):不在表里的不生效指令一律落在「其他声明」,不会漏标。 +const TAG_GROUP: Readonly> = { + "exclude-match": "match", + matchaboutblank: "match", +}; + /** 传给权限行的兼容性标记与跳转入口 */ export interface CompatView { marks: CompatMarks; @@ -37,13 +50,17 @@ export interface CompatView { onJump?: (line: number) => void; } +/** 该权限行要额外呈现的不生效指令(只有 match 组落在既有权限行上,其余归「其他声明」) */ +export const tagsForGroup = (marks: CompatMarks, group: IneffectiveTagGroup): IneffectiveTag[] => + marks.tags.filter((tag) => tag.group === group); + /** 不生效项总数,用于卡头徽章 */ -export const compatMarkCount = (marks: CompatMarks): number => marks.grants.size + marks.tags.length; +export const compatMarkCount = (marks: CompatMarks): number => + marks.grants.size + marks.matches.size + marks.tags.length; /** * 派生安装页的兼容性标记:脚本写了、但脚本猫不会执行的指令与 GM 能力。 - * 判定是二元的(见 script_compat.ts),这里只负责定位,不再分兼容程度。 - * 表外的指令不按名字猜它会影响哪一类权限:那需要一份不支持指令的清单,而别家新增的指令永远追不上。 + * 判定是二元的(见 script_compat.ts),这里只负责定位与归组,不再分兼容程度。 */ export function deriveCompatMarks(metadata: SCMetadata, code: string): CompatMarks { const lines = parseMetadataLines(code); @@ -66,6 +83,8 @@ export function deriveCompatMarks(metadata: SCMetadata, code: string): CompatMar grants.set(grant, lineOf("grant", index, grant)); }); + const nameOf = (tag: string) => tagLines.get(tag)?.[0].name ?? tag; + const byLine = (a: { line: number | undefined }, b: { line: number | undefined }) => (a.line ?? Infinity) - (b.line ?? Infinity); @@ -80,13 +99,23 @@ export function deriveCompatMarks(metadata: SCMetadata, code: string): CompatMar const tag = resolveMetadataTagBase(rawTag); if (seen.has(tag)) continue; seen.add(tag); - if (!isSupportedMetadataTag(tag)) tags.push({ tag, line: lineOf(tag) }); - else if (SCRIPTCAT_ONLY_METADATA_TAGS.has(tag)) scriptcatOnlyTags.push({ tag, line: lineOf(tag) }); + if (!isSupportedMetadataTag(tag)) { + tags.push({ tag: nameOf(tag), group: TAG_GROUP[tag] ?? "other", line: lineOf(tag) }); + } else if (SCRIPTCAT_ONLY_METADATA_TAGS.has(tag)) { + scriptcatOnlyTags.push({ tag: nameOf(tag), line: lineOf(tag) }); + } } + const matches = new Map(); for (const { tag, index, value } of ineffectiveValues) { - tags.push({ tag, value, line: lineOf(tag, index, value) }); + const line = lineOf(tag, index, value); + // @match 的取值本身就是运行网站行里的一枚 chip,就地打标,不再另成一条 + if (tag === "match") { + if (!matches.has(value)) matches.set(value, line); + } else { + tags.push({ tag: nameOf(tag), value, group: "other", line }); + } } tags.sort(byLine); - return { grants, tags, scriptcatOnlyTags }; + return { grants, matches, tags, scriptcatOnlyTags }; } diff --git a/src/pages/install/compat_docs.test.ts b/src/pages/install/compat_docs.test.ts new file mode 100644 index 000000000..93ab94378 --- /dev/null +++ b/src/pages/install/compat_docs.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect } from "vitest"; +import { catApiDocHref, metadataDocHref } from "./compat_docs"; + +describe("兼容性标记的文档地址", () => { + it("文档有对应小节的指令链到该小节,大小写不敏感", () => { + expect(metadataDocHref("run-at")).toBe("https://docs.scriptcat.org/docs/dev/meta#run-at"); + expect(metadataDocHref("storageName")).toBe("https://docs.scriptcat.org/docs/dev/meta#storagename-"); + expect(metadataDocHref("early-start")).toBe("https://docs.scriptcat.org/docs/dev/meta#early-start-v110"); + }); + + it("文档里没有小节的指令不给地址", () => { + expect(metadataDocHref("unwrap")).toBeUndefined(); + expect(metadataDocHref("exclude-match")).toBeUndefined(); + }); + + it("CAT 能力只链文档里写了的那几个", () => { + expect(catApiDocHref("CAT_userConfig")).toBe("https://docs.scriptcat.org/docs/dev/cat-api#cat_userconfig"); + expect(catApiDocHref("CAT_createBlobUrl")).toBeUndefined(); + }); +}); diff --git a/src/pages/install/compat_docs.ts b/src/pages/install/compat_docs.ts new file mode 100644 index 000000000..f53692297 --- /dev/null +++ b/src/pages/install/compat_docs.ts @@ -0,0 +1,34 @@ +import { DocumentationSite } from "@App/app/const"; +import { localePath } from "@App/locales/locales"; + +// 文档站(scriptcat.org 仓库 docs/dev/*.md)里有独立小节的条目 → Docusaurus 生成的锚点,中英文站一致。 +// 不在表里的条目文档里没有说明(不支持的指令与 API 本来就不会写进文档),不给链接,免得点过去找不到。 +// 锚点随标题文字生成,文档改标题后这里会退化成跳到页首,不会跳错地方。 +const META_ANCHORS: Readonly> = { + "run-at": "run-at", + "run-in": "run-in", + "early-start": "early-start-v110", + "inject-into": "inject-into", + storagename: "storagename-", + background: "background", + crontab: "crontab", + match: "match", + "require-css": "require-css", +}; + +const CAT_API_ANCHORS: Readonly> = { + CAT_userConfig: "cat_userconfig", + CAT_fileStorage: "cat_filestorage", + CAT_scriptLoaded: "cat_scriptloaded", +}; + +/** 元数据指令在描述文档里的小节地址;localePath 随界面语言切换,须在调用时读取 */ +export const metadataDocHref = (tag: string): string | undefined => { + const anchor = META_ANCHORS[tag.toLowerCase()]; + return anchor ? `${DocumentationSite}${localePath}/docs/dev/meta#${anchor}` : undefined; +}; + +export const catApiDocHref = (grant: string): string | undefined => { + const anchor = CAT_API_ANCHORS[grant]; + return anchor ? `${DocumentationSite}${localePath}/docs/dev/cat-api#${anchor}` : undefined; +}; diff --git a/src/pages/install/components/CompatChip.test.tsx b/src/pages/install/components/CompatChip.test.tsx index 6e2b4c236..1046ad788 100644 --- a/src/pages/install/components/CompatChip.test.tsx +++ b/src/pages/install/components/CompatChip.test.tsx @@ -52,7 +52,14 @@ describe("CompatChip 不生效标记", () => { it("鼠标从 chip 移到浮层上不会关闭——否则浮层里的文档链接永远点不到", async () => { vi.useFakeTimers(); try { - render(); + render( + + ); fireEvent.mouseEnter(screen.getByTestId("compat-chip")); const popover = screen.getByTestId("compat-popover"); fireEvent.mouseEnter(popover); @@ -97,14 +104,24 @@ describe("CompatChip 不生效标记", () => { expect(screen.queryByText(/跳到第/)).not.toBeInTheDocument(); }); - it("浮层提供兼容性文档链接,元数据与 GM 能力各自指向对应文档页", () => { - const { unmount } = render(); + it("给了文档地址时浮层链过去,文案说明是描述文档", () => { + render( + + ); fireEvent.mouseEnter(screen.getByTestId("compat-chip")); - expect(screen.getByTestId("compat-docs")).toHaveAttribute("href", expect.stringContaining("/docs/dev/meta")); - unmount(); + const link = screen.getByTestId("compat-docs"); + expect(link).toHaveAttribute("href", "https://docs.scriptcat.org/docs/dev/meta#run-at"); + expect(link).toHaveTextContent("描述文档"); + }); + + it("文档里没有对应说明时不给链接", () => { render(); fireEvent.mouseEnter(screen.getByTestId("compat-chip")); - expect(screen.getByTestId("compat-docs")).toHaveAttribute("href", expect.stringContaining("/docs/dev/api")); + expect(screen.queryByTestId("compat-docs")).not.toBeInTheDocument(); }); }); diff --git a/src/pages/install/components/CompatChip.tsx b/src/pages/install/components/CompatChip.tsx index 382d937f7..649d07900 100644 --- a/src/pages/install/components/CompatChip.tsx +++ b/src/pages/install/components/CompatChip.tsx @@ -1,8 +1,6 @@ import { useEffect } from "react"; import { useTranslation } from "react-i18next"; import { Ban, ExternalLink } from "lucide-react"; -import { DocumentationSite } from "@App/app/const"; -import { localePath } from "@App/locales/locales"; import { Popover, PopoverAnchor, PopoverContent } from "@App/pages/components/ui/popover"; import { useHoverMenu } from "@App/pages/components/ui/use-hover-menu"; @@ -12,12 +10,6 @@ export type CompatChipKind = "metadata" | "value" | "grant"; // 悬停切换时旧浮层要等关闭延迟才收,靠这里立刻收掉。 let closeActivePopover: (() => void) | null = null; -const DOC_PATH: Record = { - metadata: "/docs/dev/meta", - value: "/docs/dev/meta", - grant: "/docs/dev/api", -}; - const DESC_KEY: Record = { metadata: "install:compat_tag_desc", value: "install:compat_value_desc", @@ -34,11 +26,14 @@ export function CompatChip({ kind, line, onJump, + docHref, }: { label: string; kind: CompatChipKind; line?: number; onJump?: (line: number) => void; + /** 文档里有对应说明时才传(见 compat_docs.ts) */ + docHref?: string; }) { const { t } = useTranslation(["install", "common"]); // 浮层里有文档链接,必须把浮层本体也纳入悬停范围:只盯 chip 的话鼠标一移向链接浮层就关了 @@ -90,17 +85,19 @@ export function CompatChip({ {t("install:compat_jump", { line })} )} - - {t("install:compat_docs")} - + {docHref && ( + + {t("install:compat_docs")} + + )} @@ -108,14 +105,26 @@ export function CompatChip({ } /** 在脚本猫里生效、换到别的脚本管理器就不生效的指令或 API */ -export function ScriptCatOnlyBadge() { +export function ScriptCatOnlyBadge({ docHref }: { docHref?: string }) { const { t } = useTranslation(["install", "common"]); + const className = "shrink-0 rounded bg-primary-light px-1 font-sans text-[10px] font-medium text-primary"; + if (!docHref) { + return ( + + {t("install:compat_scriptcat_only")} + + ); + } return ( - {t("install:compat_scriptcat_only")} - + ); } diff --git a/src/pages/install/components/PermissionCard.test.tsx b/src/pages/install/components/PermissionCard.test.tsx index a8bc562f9..2cdb1fc67 100644 --- a/src/pages/install/components/PermissionCard.test.tsx +++ b/src/pages/install/components/PermissionCard.test.tsx @@ -210,14 +210,19 @@ describe("PermissionCard 上的不生效标记", () => { ); expect(screen.getByText("2 项不生效")).toBeInTheDocument(); }); - it("不生效的指令统一落在其他声明行,不按指令名猜它属于哪个权限类别", () => { + it("归不到任何权限类别的指令单独成行,只在有内容时出现", () => { render( { marks: { grants: new Map(), tags: [ - { tag: "exclude-match", line: 3 }, - { tag: "sandbox", line: 4 }, + { tag: "top-level-await", group: "other", line: 3 }, + { tag: "sandbox", group: "other", line: 4 }, ], + matches: new Map(), scriptcatOnlyTags: [], }, }} @@ -239,12 +245,17 @@ describe("PermissionCard 上的不生效标记", () => { within(row) .getAllByTestId("compat-chip") .map((chip) => chip.textContent) - ).toEqual(["@exclude-match", "@sandbox"]); + ).toEqual(["@top-level-await", "@sandbox"]); expect(screen.getAllByTestId("compat-chip")).toHaveLength(2); }); it("没有不生效项时既无徽章也无其他声明行——全兼容的安装页一字不改", () => { - render(); + render( + + ); expect(screen.queryByTestId("permission-row-other")).not.toBeInTheDocument(); expect(screen.queryByText(/项不生效/)).not.toBeInTheDocument(); }); @@ -260,7 +271,8 @@ describe("PermissionCard 其他声明行的取值与脚本猫独有指令", () = compat={{ marks: { grants: new Map(), - tags: [{ tag: "run-at", value: "document-weird", line: 3 }], + tags: [{ tag: "run-at", value: "document-weird", group: "other", line: 3 }], + matches: new Map(), scriptcatOnlyTags: [], }, }} @@ -271,11 +283,67 @@ describe("PermissionCard 其他声明行的取值与脚本猫独有指令", () = ); }); + it("取值类标记的浮层链到文档里该指令的小节,不支持的指令本身不给链接", () => { + render( + + ); + const [value, unsupported] = within(screen.getByTestId("permission-row-other")).getAllByTestId("compat-chip"); + fireEvent.mouseEnter(value); + expect(screen.getByTestId("compat-docs")).toHaveAttribute( + "href", + expect.stringMatching(/\/docs\/dev\/meta#run-at$/) + ); + expect(screen.getByTestId("compat-docs")).toHaveTextContent("描述文档"); + fireEvent.mouseEnter(unsupported); + expect(screen.queryByTestId("compat-docs")).not.toBeInTheDocument(); + }); + + it("脚本猫独有的指令标签链到文档对应小节", () => { + render( + + ); + expect(screen.getByText("@storageName")).toBeInTheDocument(); + expect(screen.getByTestId("scriptcat-only")).toHaveAttribute( + "href", + expect.stringMatching(/\/docs\/dev\/meta#storagename-$/) + ); + }); + it("脚本猫独有的指令带仅限脚本猫标签,不算进不生效计数", () => { render( ); const row = screen.getByTestId("permission-row-other"); @@ -296,7 +364,7 @@ describe("不生效标记与折叠形态的关系", () => { ); expect(screen.queryByTestId("permission-card-collapsed")).not.toBeInTheDocument(); @@ -308,7 +376,7 @@ describe("不生效标记与折叠形态的关系", () => { ); expect(screen.getByTestId("permission-card-collapsed")).toBeInTheDocument(); @@ -324,7 +392,28 @@ describe("移动端的不生效标记", () => { { kind: "match", risk: "normal", values: ["*://a.com/*"], sensitive: [] }, { kind: "grant", risk: "warn", values: ["GM_audio"], sensitive: [] }, ]} - compat={{ marks: { grants: new Map([["GM_audio", 9]]), tags: [], scriptcatOnlyTags: [] } }} + compat={{ marks: { grants: new Map([["GM_audio", 9]]), tags: [], matches: new Map(), scriptcatOnlyTags: [] } }} + /> + ); + mobile = false; + expect(screen.getByTestId("compat-chip")).toBeVisible(); + }); +}); + +describe("移动端运行网站行的不生效标记", () => { + it("运行网站行挂了不生效标记时默认展开", () => { + mobile = true; + render( + ); mobile = false; diff --git a/src/pages/install/components/PermissionCard.tsx b/src/pages/install/components/PermissionCard.tsx index 4880dea73..a967bde92 100644 --- a/src/pages/install/components/PermissionCard.tsx +++ b/src/pages/install/components/PermissionCard.tsx @@ -4,7 +4,8 @@ import { ChevronDown, FileCode2, ShieldCheck } from "lucide-react"; import { cn } from "@App/pkg/utils/cn"; import { useIsMobile } from "@App/pages/components/use-is-mobile"; import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from "@App/pages/components/ui/accordion"; -import { compatMarkCount, type CompatView } from "../compat"; +import { compatMarkCount, tagsForGroup, type CompatView } from "../compat"; +import { metadataDocHref } from "../compat_docs"; import { CompatChip, ScriptCatOnlyBadge } from "./CompatChip"; import { isPermissionChanged, type PermissionRow as PermissionRowData } from "../permissions"; import { PermissionRow, PermissionChips, PermissionDelta, NoChangeTag, KIND_META, RISK_STYLE } from "./PermissionRow"; @@ -14,8 +15,14 @@ function MobilePermissions({ rows, compat }: { rows: PermissionRowData[]; compat // 有变动时默认只展开有变动的类别;全新安装、以及用户主动点开的零变化整卡都退回只展开高风险项, // 否则零变化整卡展开后每一类都是收起的,「点开即得到全量清单」在移动端会落空。 const hasChanged = rows.some(isPermissionChanged); - const isMarked = (row: PermissionRowData) => - row.kind === "grant" && row.values.some((v) => compat?.marks.grants.has(v)); + const isMarked = (row: PermissionRowData) => { + if (!compat) return false; + const { marks } = compat; + if (row.kind === "grant") return row.values.some((v) => marks.grants.has(v)); + if (row.kind === "match") + return row.values.some((v) => marks.matches.has(v)) || tagsForGroup(marks, "match").length > 0; + return false; + }; const defaultValue = rows .filter((r) => isMarked(r) || (hasChanged ? isPermissionChanged(r) : r.risk === "danger")) .map((r) => r.kind); @@ -121,13 +128,14 @@ function CollapsedCard({ } /** - * 与别家脚本管理器行为不同的元数据声明:脚本猫不会执行的指令或取值(@exclude-match、@run-at document-weird), + * 归不到任何权限类别、且与别家脚本管理器行为不同的元数据声明:脚本猫不会执行的指令或取值(@sandbox、@run-at document-weird), * 以及只有脚本猫认的指令(@early-start、@background)。 * 它们不是权限,但同样是「脚本写了、脚本猫不会执行」,与权限行同列才对得起读者的一次扫视。 */ function OtherDirectivesRow({ compat }: { compat: CompatView }) { const { t } = useTranslation(["install", "common"]); - const { tags, scriptcatOnlyTags } = compat.marks; + const { scriptcatOnlyTags } = compat.marks; + const tags = tagsForGroup(compat.marks, "other"); if (!tags.length && !scriptcatOnlyTags.length) return null; return ( @@ -148,6 +156,7 @@ function OtherDirectivesRow({ compat }: { compat: CompatView }) { kind={tag.value === undefined ? "metadata" : "value"} line={tag.line} onJump={compat.onJump} + docHref={tag.value === undefined ? undefined : metadataDocHref(tag.tag)} /> ))} {scriptcatOnlyTags.map((tag) => ( @@ -157,7 +166,7 @@ function OtherDirectivesRow({ compat }: { compat: CompatView }) { className="inline-flex max-w-full items-center gap-1 rounded-md border border-border bg-muted px-2 py-0.5 font-mono text-xs text-fg-secondary" > {`@${tag.tag}`} - + ))} @@ -211,7 +220,7 @@ export function PermissionCard({ {compatCount > 0 && ( {t("install:compat_count", { count: compatCount })} diff --git a/src/pages/install/components/PermissionRow.test.tsx b/src/pages/install/components/PermissionRow.test.tsx index 143b328e0..17bf5cfaa 100644 --- a/src/pages/install/components/PermissionRow.test.tsx +++ b/src/pages/install/components/PermissionRow.test.tsx @@ -152,8 +152,14 @@ describe("PermissionRow 零变动行的取值折叠", () => { }); describe("PermissionRow 上的不生效标记", () => { - const compat = (over: Partial<{ grants: Map; tags: IneffectiveTag[] }> = {}) => ({ - marks: { grants: new Map(), tags: [], scriptcatOnlyTags: [], ...over }, + const compat = ( + over: Partial<{ + grants: Map; + matches: Map; + tags: IneffectiveTag[]; + }> = {} + ) => ({ + marks: { grants: new Map(), tags: [], matches: new Map(), scriptcatOnlyTags: [], ...over }, }); it("不受支持的 GM 能力就地换成不生效标记,其余 chip 不变", () => { @@ -170,16 +176,44 @@ describe("PermissionRow 上的不生效标记", () => { expect(screen.getByText("GM_setValue").closest('[data-testid="compat-chip"]')).toBeNull(); }); - it("不生效的元数据指令不挂到权限行上", () => { + it("不生效的匹配类指令追加到运行网站行——它本该影响的就是这一行", () => { render( + ); + expect(screen.getByTestId("compat-chip")).toHaveTextContent("@exclude-match"); + }); + + it("其他类别的行不会被别的组的标记污染", () => { + render( + ); expect(screen.queryByTestId("compat-chip")).not.toBeInTheDocument(); }); + it("解析不出规则的 @match 取值就地换成不生效标记,并链到文档的 match 小节", () => { + render( + + ); + const marks = screen.getAllByTestId("compat-chip"); + expect(marks).toHaveLength(1); + expect(marks[0]).toHaveTextContent("hello-world^^"); + expect(screen.getAllByText("hello-world^^")).toHaveLength(1); + fireEvent.mouseEnter(marks[0]); + expect(screen.getByTestId("compat-docs")).toHaveAttribute( + "href", + expect.stringMatching(/\/docs\/dev\/meta#match$/) + ); + }); + it("更新态里被移除的能力不标记——它已经不在新版本里了", () => { render( { expect(screen.queryByTestId("scriptcat-only")).not.toBeInTheDocument(); }); }); + +describe("仅限脚本猫标签的文档链接", () => { + it("文档里有对应小节的 CAT 能力,标签链到该小节", () => { + render(); + expect(screen.getByTestId("scriptcat-only")).toHaveAttribute( + "href", + expect.stringMatching(/\/docs\/dev\/cat-api#cat_filestorage$/) + ); + }); + + it("文档里没有说明的 CAT 能力只标不链——点过去找不到比不给链接更糟", () => { + render(); + expect(screen.getByTestId("scriptcat-only")).not.toHaveAttribute("href"); + }); +}); diff --git a/src/pages/install/components/PermissionRow.tsx b/src/pages/install/components/PermissionRow.tsx index 4377e9833..df70ae647 100644 --- a/src/pages/install/components/PermissionRow.tsx +++ b/src/pages/install/components/PermissionRow.tsx @@ -3,7 +3,8 @@ import { useTranslation } from "react-i18next"; import { Globe, ArrowLeftRight, ChevronDown, KeyRound, Package, TriangleAlert, type LucideIcon } from "lucide-react"; import { cn } from "@App/pkg/utils/cn"; import { isScriptCatOnlyGrant } from "@App/pkg/utils/script_compat"; -import type { CompatView } from "../compat"; +import { tagsForGroup, type CompatView } from "../compat"; +import { catApiDocHref, metadataDocHref } from "../compat_docs"; import { CompatChip, ScriptCatOnlyBadge } from "./CompatChip"; import { isPermissionChanged, @@ -81,7 +82,7 @@ function Chip({ value, row, change }: { value: string; row: PermissionRowData; c {isSensitive && change !== "removed" && } {change && {t(CHANGE_LABEL_KEY[change])}} {value} - {isScriptCatOnly && } + {isScriptCatOnly && } ); } @@ -116,15 +117,42 @@ export function PermissionChips({ const { t } = useTranslation(["install", "common"]); const [expanded, setExpanded] = useState(false); - // 不受支持的 @grant 就地换成不生效标记;已被移除的取值不标——它已经不在新版本里了 + // 不受支持的 @grant、解析不出规则的 @match 就地换成不生效标记;已被移除的取值不标——它已经不在新版本里了 const renderChip = (value: string, change?: ChangeState) => { - const line = row.kind === "grant" && change !== "removed" ? compat?.marks.grants.get(value) : undefined; - if (row.kind === "grant" && change !== "removed" && compat?.marks.grants.has(value)) { - return ; + if (compat && change !== "removed") { + if (row.kind === "grant" && compat.marks.grants.has(value)) { + return ( + + ); + } + if (row.kind === "match" && compat.marks.matches.has(value)) { + return ( + + ); + } } return ; }; + // 权限卡里没有对应 chip 的不生效指令,追加到它本该影响的这一行 + const appended = compat && row.kind === "match" ? tagsForGroup(compat.marks, "match") : []; + const appendedChips = appended.map((tag) => ( + + )); + if (!row.diff) { const visible = expanded ? row.values : row.values.slice(0, maxVisible); const hidden = row.values.length - visible.length; @@ -132,6 +160,7 @@ export function PermissionChips({
{visible.map((v) => renderChip(v))} {hidden > 0 && setExpanded(true)} />} + {appendedChips}
); } @@ -156,6 +185,7 @@ export function PermissionChips({ onClick={() => setExpanded(true)} /> )} + {appendedChips} ); } diff --git a/src/pages/install/useInstallData.test.ts b/src/pages/install/useInstallData.test.ts index d9619b3cb..435d082de 100644 --- a/src/pages/install/useInstallData.test.ts +++ b/src/pages/install/useInstallData.test.ts @@ -132,7 +132,7 @@ describe("assembleInstallView 组装安装视图", () => { oldVersion: null, }); expect(view.compat.grants).toEqual(new Map([["GM_audio", 4]])); - expect(view.compat.tags).toEqual([{ tag: "exclude-match", line: 3 }]); + expect(view.compat.tags).toEqual([{ tag: "exclude-match", group: "match", line: 3 }]); expect(view.compat.scriptcatOnlyTags).toEqual([]); }); diff --git a/src/pkg/utils/script.test.ts b/src/pkg/utils/script.test.ts index 48acb1c1e..a1850bfc1 100644 --- a/src/pkg/utils/script.test.ts +++ b/src/pkg/utils/script.test.ts @@ -739,11 +739,11 @@ console.log(1); it("逐条给出指令名、取值与 1 起算的全文行号", () => { expect(parseMetadataLines(code)).toEqual([ - { tag: "name", value: "示例", line: 3 }, - { tag: "namespace", value: "https://example.com", line: 4 }, - { tag: "match", value: "*://example.com/*", line: 5 }, - { tag: "exclude-match", value: "*://live.example.com/*", line: 6 }, - { tag: "grant", value: "GM_setValue", line: 7 }, + { tag: "name", name: "name", value: "示例", line: 3 }, + { tag: "namespace", name: "namespace", value: "https://example.com", line: 4 }, + { tag: "match", name: "match", value: "*://example.com/*", line: 5 }, + { tag: "exclude-match", name: "exclude-match", value: "*://live.example.com/*", line: 6 }, + { tag: "grant", name: "grant", value: "GM_setValue", line: 7 }, ]); }); @@ -753,6 +753,8 @@ console.log(1); // @MATCH *://a.com/* // ==/UserScript==`); expect(lines.map((l) => l.tag)).toEqual(["name", "match"]); + // 呈现给用户时要用作者的原始写法 + expect(lines.map((l) => l.name)).toEqual(["Name", "MATCH"]); }); it("同名指令重复出现时逐条保留,不合并", () => { @@ -775,7 +777,7 @@ console.log(1); // ==UserScript== // @name Y // ==/UserScript==`); - expect(lines).toEqual([{ tag: "name", value: "X", line: 2 }]); + expect(lines).toEqual([{ tag: "name", name: "name", value: "X", line: 2 }]); }); it("区块未闭合时不产出任何指令——与 parseMetadata 一致", () => { diff --git a/src/pkg/utils/script.ts b/src/pkg/utils/script.ts index 8af95fe9f..877dd63a1 100644 --- a/src/pkg/utils/script.ts +++ b/src/pkg/utils/script.ts @@ -24,6 +24,8 @@ const META_LINE = /\/\/[ \t]*@(\S+)[ \t]*(.*)$/gm; export interface MetadataLine { /** 小写归一后的指令名,与 parseMetadata 的取键一致 */ tag: string; + /** 作者的原始写法,仅供呈现 */ + name: string; value: string; /** 1 起算的全文行号 */ line: number; @@ -56,7 +58,7 @@ export function parseMetadataLines(code: string): MetadataLine[] { if (code.charCodeAt(i) === 10) line += 1; } scanned = absolute; - lines.push({ tag: m[1].toLowerCase(), value: m[2]?.trim() ?? "", line }); + lines.push({ tag: m[1].toLowerCase(), name: m[1], value: m[2]?.trim() ?? "", line }); } return lines; } From 6681fbeb93c82de5c611634df5af4ed4513aec38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Wed, 16 Sep 2026 17:14:48 +0800 Subject: [PATCH 8/9] =?UTF-8?q?=F0=9F=92=84=20=E5=BD=92=E8=A1=8C=E5=90=8D?= =?UTF-8?q?=E5=8D=95=E5=8F=AA=E6=94=B6=20Tampermonkey=20/=20Violentmonkey?= =?UTF-8?q?=20=E7=9A=84=E6=8C=87=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @matchAboutBlank 是 FireMonkey 特有的,移出运行网站行的名单;它仍按支持表标为不生效,落在其他声明行。 --- src/pages/install/compat.test.ts | 5 +++++ src/pages/install/compat.ts | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/pages/install/compat.test.ts b/src/pages/install/compat.test.ts index 9e7f3e973..32480aee9 100644 --- a/src/pages/install/compat.test.ts +++ b/src/pages/install/compat.test.ts @@ -110,4 +110,9 @@ describe("安装页兼容性标记", () => { expect(marks.tags).toEqual([{ tag: "early-start", value: "", group: "other", line: 3 }]); expect(marks.scriptcatOnlyTags).toEqual([]); }); + + it("归行名单只收 Tampermonkey / Violentmonkey 的指令,别家的不生效指令落在其他组", () => { + const { code, metadata } = build(`// @name X\n// @matchAboutBlank true\n`); + expect(deriveCompatMarks(metadata, code).tags).toEqual([{ tag: "matchAboutBlank", group: "other", line: 3 }]); + }); }); diff --git a/src/pages/install/compat.ts b/src/pages/install/compat.ts index d7b3715ce..b6fe0dc42 100644 --- a/src/pages/install/compat.ts +++ b/src/pages/install/compat.ts @@ -38,9 +38,9 @@ export interface CompatMarks { // 已知会影响「运行网站」的指令挂到那一行,读者才能就地判断后果。这里只决定摆放位置, // 不决定支不支持(那由 script_compat.ts 的支持表判定):不在表里的不生效指令一律落在「其他声明」,不会漏标。 +// 只收 Tampermonkey / Violentmonkey 现行文档里的指令,别家特有的与旧写法不收。 const TAG_GROUP: Readonly> = { "exclude-match": "match", - matchaboutblank: "match", }; /** 传给权限行的兼容性标记与跳转入口 */ From da08a9e7810479122500248bb5d1c99acd8f6d4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Wed, 16 Sep 2026 17:18:20 +0800 Subject: [PATCH 9/9] =?UTF-8?q?=F0=9F=90=9B=20=E4=B8=8D=E7=94=9F=E6=95=88?= =?UTF-8?q?=E6=A0=87=E8=AE=B0=E4=B8=8D=E5=86=8D=E8=A2=AB=E6=8A=98=E5=8F=A0?= =?UTF-8?q?=E8=97=8F=E8=B5=B7=E6=9D=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GM 能力超过 8 项时排在后面的不生效能力会收进「+N」;更新时属于未变动的会收进折叠桶, 整行未变动时整行塌成单行——三种情况下标记都看不到。带标记的取值现在不参与截断与折叠, 带标记的类别不塌行(移动端本来就默认展开)。 --- src/pages/install/compat.ts | 10 +++ .../components/PermissionCard.test.tsx | 23 +++++++ .../install/components/PermissionCard.tsx | 16 ++--- .../install/components/PermissionRow.test.tsx | 62 +++++++++++++++++++ .../install/components/PermissionRow.tsx | 10 ++- 5 files changed, 107 insertions(+), 14 deletions(-) diff --git a/src/pages/install/compat.ts b/src/pages/install/compat.ts index b6fe0dc42..1efba3a8e 100644 --- a/src/pages/install/compat.ts +++ b/src/pages/install/compat.ts @@ -1,4 +1,5 @@ import type { SCMetadata } from "@App/app/repo/metadata"; +import type { PermissionKind, PermissionRow } from "./permissions"; import { parseMetadataLines, type MetadataLine } from "@App/pkg/utils/script"; import { SCRIPTCAT_ONLY_METADATA_TAGS, @@ -54,6 +55,15 @@ export interface CompatView { export const tagsForGroup = (marks: CompatMarks, group: IneffectiveTagGroup): IneffectiveTag[] => marks.tags.filter((tag) => tag.group === group); +/** 该取值在这一类权限行里是否带不生效标记 */ +export const isMarkedValue = (marks: CompatMarks, kind: PermissionKind, value: string): boolean => + (kind === "grant" && marks.grants.has(value)) || (kind === "match" && marks.matches.has(value)); + +/** 该权限行是否带不生效标记(含追加到这一行的指令);带标记的行不能被折叠藏起来 */ +export const rowHasCompatMarks = (marks: CompatMarks, row: PermissionRow): boolean => + row.values.some((value) => isMarkedValue(marks, row.kind, value)) || + (row.kind === "match" && tagsForGroup(marks, "match").length > 0); + /** 不生效项总数,用于卡头徽章 */ export const compatMarkCount = (marks: CompatMarks): number => marks.grants.size + marks.matches.size + marks.tags.length; diff --git a/src/pages/install/components/PermissionCard.test.tsx b/src/pages/install/components/PermissionCard.test.tsx index 2cdb1fc67..46a745938 100644 --- a/src/pages/install/components/PermissionCard.test.tsx +++ b/src/pages/install/components/PermissionCard.test.tsx @@ -383,6 +383,29 @@ describe("不生效标记与折叠形态的关系", () => { }); }); +describe("有变动时未变动类别的塌行与不生效标记", () => { + it("未变动但带不生效标记的类别不塌成单行", () => { + render( + + ); + expect(screen.queryByTestId("permission-row-collapsed")).not.toBeInTheDocument(); + expect(screen.getByTestId("compat-chip")).toHaveTextContent("GM_audio"); + }); +}); + describe("移动端的不生效标记", () => { it("有不生效项的类别默认展开,否则标记藏在折叠面板里等于没做", () => { mobile = true; diff --git a/src/pages/install/components/PermissionCard.tsx b/src/pages/install/components/PermissionCard.tsx index a967bde92..3bf6bc208 100644 --- a/src/pages/install/components/PermissionCard.tsx +++ b/src/pages/install/components/PermissionCard.tsx @@ -4,7 +4,7 @@ import { ChevronDown, FileCode2, ShieldCheck } from "lucide-react"; import { cn } from "@App/pkg/utils/cn"; import { useIsMobile } from "@App/pages/components/use-is-mobile"; import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from "@App/pages/components/ui/accordion"; -import { compatMarkCount, tagsForGroup, type CompatView } from "../compat"; +import { compatMarkCount, rowHasCompatMarks, tagsForGroup, type CompatView } from "../compat"; import { metadataDocHref } from "../compat_docs"; import { CompatChip, ScriptCatOnlyBadge } from "./CompatChip"; import { isPermissionChanged, type PermissionRow as PermissionRowData } from "../permissions"; @@ -15,14 +15,7 @@ function MobilePermissions({ rows, compat }: { rows: PermissionRowData[]; compat // 有变动时默认只展开有变动的类别;全新安装、以及用户主动点开的零变化整卡都退回只展开高风险项, // 否则零变化整卡展开后每一类都是收起的,「点开即得到全量清单」在移动端会落空。 const hasChanged = rows.some(isPermissionChanged); - const isMarked = (row: PermissionRowData) => { - if (!compat) return false; - const { marks } = compat; - if (row.kind === "grant") return row.values.some((v) => marks.grants.has(v)); - if (row.kind === "match") - return row.values.some((v) => marks.matches.has(v)) || tagsForGroup(marks, "match").length > 0; - return false; - }; + const isMarked = (row: PermissionRowData) => !!compat && rowHasCompatMarks(compat.marks, row); const defaultValue = rows .filter((r) => isMarked(r) || (hasChanged ? isPermissionChanged(r) : r.risk === "danger")) .map((r) => r.kind); @@ -237,8 +230,9 @@ export function PermissionCard({ ) : ( rows.map((row, i) => (
0 && "border-t border-border")}> - {/* 有变动时未变动的类别塌成单行让位;整卡零变化时用户是主动点开的,给全量 */} - {changed.length > 0 && !isPermissionChanged(row) ? ( + {/* 有变动时未变动的类别塌成单行让位;整卡零变化时用户是主动点开的,给全量。 + 带不生效标记的类别不塌:标记是这次才出现的新信息 */} + {changed.length > 0 && !isPermissionChanged(row) && !(compat && rowHasCompatMarks(compat.marks, row)) ? ( ) : ( diff --git a/src/pages/install/components/PermissionRow.test.tsx b/src/pages/install/components/PermissionRow.test.tsx index 17bf5cfaa..ffd3c95de 100644 --- a/src/pages/install/components/PermissionRow.test.tsx +++ b/src/pages/install/components/PermissionRow.test.tsx @@ -281,3 +281,65 @@ describe("仅限脚本猫标签的文档链接", () => { expect(screen.getByTestId("scriptcat-only")).not.toHaveAttribute("href"); }); }); + +describe("不生效标记不会被折叠藏起来", () => { + const marks = (grants: [string, number][]) => ({ + marks: { grants: new Map(grants), matches: new Map(), tags: [], scriptcatOnlyTags: [] }, + }); + + it("取值超过 maxVisible 时,排在后面的不生效能力仍然可见", () => { + render( + + ); + const row = screen.getByTestId("permission-row"); + expect(within(row).getByTestId("compat-chip")).toHaveTextContent("GM_audio"); + expect(within(row).queryByText("GM_log")).not.toBeInTheDocument(); + expect(within(row).getByTestId("permission-more")).toHaveTextContent("+1"); + }); + + it("更新态里属于未变动的不生效能力不收进折叠桶", () => { + render( + + ); + const row = screen.getByTestId("permission-row"); + expect(within(row).getByTestId("compat-chip")).toHaveTextContent("GM_audio"); + expect(within(row).queryByText("GM_setValue")).not.toBeInTheDocument(); + expect(within(row).getByTestId("permission-more")).toHaveTextContent("未变动 1 项"); + }); + + it("零变动行超过 maxVisible 时,不生效能力同样可见", () => { + render( + + ); + expect(screen.getByTestId("compat-chip")).toHaveTextContent("GM_audio"); + expect(screen.getByTestId("permission-more")).toHaveTextContent("+1"); + }); +}); diff --git a/src/pages/install/components/PermissionRow.tsx b/src/pages/install/components/PermissionRow.tsx index df70ae647..3fc12d987 100644 --- a/src/pages/install/components/PermissionRow.tsx +++ b/src/pages/install/components/PermissionRow.tsx @@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next"; import { Globe, ArrowLeftRight, ChevronDown, KeyRound, Package, TriangleAlert, type LucideIcon } from "lucide-react"; import { cn } from "@App/pkg/utils/cn"; import { isScriptCatOnlyGrant } from "@App/pkg/utils/script_compat"; -import { tagsForGroup, type CompatView } from "../compat"; +import { isMarkedValue, tagsForGroup, type CompatView } from "../compat"; import { catApiDocHref, metadataDocHref } from "../compat_docs"; import { CompatChip, ScriptCatOnlyBadge } from "./CompatChip"; import { @@ -153,8 +153,12 @@ export function PermissionChips({ )); + // 带不生效标记的取值不参与截断与折叠,否则标记会藏在「+N」后面 + const marked = (value: string) => !!compat && isMarkedValue(compat.marks, row.kind, value); + const truncate = (values: string[]) => values.filter((v, i) => i < maxVisible || marked(v)); + if (!row.diff) { - const visible = expanded ? row.values : row.values.slice(0, maxVisible); + const visible = expanded ? row.values : truncate(row.values); const hidden = row.values.length - visible.length; return (
@@ -171,7 +175,7 @@ export function PermissionChips({ const pinned = added.length + removed.length; // 有增删时未变动项整体让位给折叠桶;一项没变时没有可钉住的内容,桶会变成必须点开才能看到全部的空壳, // 故退回全新安装的 maxVisible 截断——否则几十条 @match 的脚本一更新就会整片摊开。 - const visibleUnchanged = expanded ? unchanged : pinned > 0 ? [] : unchanged.slice(0, maxVisible); + const visibleUnchanged = expanded ? unchanged : pinned > 0 ? unchanged.filter(marked) : truncate(unchanged); const hidden = unchanged.length - visibleUnchanged.length; return (