diff --git a/packages/lint/src/rules/gsap.test.ts b/packages/lint/src/rules/gsap.test.ts index 6587c51596..88352e98d6 100644 --- a/packages/lint/src/rules/gsap.test.ts +++ b/packages/lint/src/rules/gsap.test.ts @@ -1008,9 +1008,42 @@ describe("GSAP rules", () => { expect(finding).toBeUndefined(); }); + it("reports missing_gsap_script when only a GSAP plugin script is loaded", async () => { + const html = ` + +
+ + +`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "missing_gsap_script"); + expect(finding?.severity).toBe("error"); + }); + + it.each(["/api/vendor/npm/gsap@3.15.0/dist/gsap.min.js", "assets/gsap-3.14.2.min.js"])( + "does not report missing_gsap_script for the local GSAP core %s", + async (src) => { + const html = ` + +
+ + +`; + const result = await lintHyperframeHtml(html); + expect(result.findings.find((f) => f.code === "missing_gsap_script")).toBeUndefined(); + }, + ); + it("does not report missing_gsap_script when GSAP is bundled inline", async () => { - // Simulate a large inline GSAP bundle (>5KB) with GreenSock marker - const fakeGsapLib = "/* GreenSock GSAP */" + " ".repeat(6000); + const fakeGsapLib = "/*!\n * GSAP 3.15.0\n * https://gsap.com\n */\n" + " ".repeat(6000); const html = `
@@ -1027,6 +1060,34 @@ describe("GSAP rules", () => { expect(finding).toBeUndefined(); }); + it.each([ + [ + "registerPlugin", + "gsap.registerPlugin(MotionPathPlugin);", + '', + ], + ["defaults", 'gsap.defaults({ ease: "none" });', ""], + ["a large GreenSock comment", "/* GreenSock */" + " ".repeat(6000), ""], + [ + "an inlined plugin", + "/* inlined: https://cdn.jsdelivr.net/npm/gsap@3/dist/MotionPathPlugin.min.js */", + "", + ], + ])("reports missing_gsap_script when the only inline GSAP is %s", async (_name, inline, tag) => { + const html = ` + +
+ ${tag} + + +`; + const result = await lintHyperframeHtml(html); + expect(result.findings.find((f) => f.code === "missing_gsap_script")?.severity).toBe("error"); + }); + it("does not report missing_gsap_script when producer inlined CDN script", async () => { const html = ` diff --git a/packages/lint/src/rules/gsap.ts b/packages/lint/src/rules/gsap.ts index b6d222b81e..3897031f39 100644 --- a/packages/lint/src/rules/gsap.ts +++ b/packages/lint/src/rules/gsap.ts @@ -1401,18 +1401,18 @@ export const gsapRules: LintRule[] = [ const usesGsap = allScriptTexts.some((t) => /gsap\.(to|from|fromTo|timeline|set|registerPlugin)\b/.test(t), ); - const hasGsapScript = allScriptSrcs.some((src) => /gsap/i.test(src)); - // Detect GSAP bundled inline (no src attribute). Match: - // - Producer's CDN-inlining comment: /* inlined: ...gsap... */ - // - GSAP library internals: _gsScope, GreenSock, gsap.config - // - Large inline scripts (>5KB) that reference gsap (likely bundled library) + const isGsapCoreSrc = (src: string) => + /(^|\/)gsap(-\d+(\.\d+)*)?(\.min)?\.js$/i.test(src.split(/[?#]/)[0] ?? ""); + const hasGsapScript = allScriptSrcs.some(isGsapCoreSrc); + // Inline core is its inlining marker, license banner or library body, never usage like gsap.defaults(). const hasInlineGsap = allScriptTexts.some( (t) => - /\/\*\s*inlined:.*gsap/i.test(t) || - /\b_gsScope\b/.test(t) || - /\bGreenSock\b/.test(t) || - /\bgsap\.(config|defaults|version)\b/.test(t) || - (t.length > 5000 && /\bgsap\b/i.test(t)), + [...t.matchAll(/\/\*\s*inlined:\s*(\S+)\s*\*\//gi)].some((m) => + isGsapCoreSrc(m[1] ?? ""), + ) || + /\/\*!\s*\*\s*GSAP\s+\d/.test(t) || + /Missing plugin\? gsap\.registerPlugin\(\)/.test(t) || + /\b_gsScope\b/.test(t), ); if (!usesGsap || hasGsapScript || hasInlineGsap || canInheritGsapFromHost) return []; diff --git a/packages/studio-server/src/helpers/gsapCoreScript.test.ts b/packages/studio-server/src/helpers/gsapCoreScript.test.ts new file mode 100644 index 0000000000..52a17bef6d --- /dev/null +++ b/packages/studio-server/src/helpers/gsapCoreScript.test.ts @@ -0,0 +1,97 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; +import { htmlHasGsapCore, insertGsapCoreBeforeHeadPlugin } from "./gsapCoreScript"; + +const script = (src: string) => ``; +const PLUGIN = "https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/MotionPathPlugin.min.js"; +const CORE = ``; + +describe("htmlHasGsapCore", () => { + it.each([ + "https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js", + "https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/gsap.js", + "/api/vendor/npm/gsap@3.15.0/dist/gsap.min.js", + "./vendor/gsap.min.js", + "assets/gsap-3.14.2.min.js", + "gsap.min.js?v=2", + ])("finds GSAP core at %s", (src) => { + expect(htmlHasGsapCore(script(src))).toBe(true); + }); + + it.each([ + "https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/MotionPathPlugin.min.js", + "https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/CustomEase.min.js", + "/api/vendor/npm/gsap@3.15.0/dist/MotionPathPlugin.min.js", + "./vendor/gsap/ScrollTrigger.js", + ])("does not treat the plugin %s as GSAP core", (src) => { + expect(htmlHasGsapCore(script(src))).toBe(false); + }); + + it("reads core from a producer inlining marker, but not a plugin's", () => { + const inlined = (src: string) => ``; + expect(htmlHasGsapCore(inlined("https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"))).toBe( + true, + ); + expect( + htmlHasGsapCore(inlined("https://cdn.jsdelivr.net/npm/gsap@3/dist/MotionPathPlugin.min.js")), + ).toBe(false); + }); + + it("reads core from its license banner, but not a plugin's", () => { + const banner = (name: string) => + ``; + expect(htmlHasGsapCore(banner("GSAP"))).toBe(true); + expect(htmlHasGsapCore(banner("MotionPathPlugin"))).toBe(false); + }); + + it.each([ + "gsap.registerPlugin(MotionPathPlugin);", + 'gsap.defaults({ ease: "none" }); gsap.config({ nullTargetWarn: false });', + 'gsap.to("#a", { x: 1 }); /* GreenSock */', + ])("does not treat the authored usage %s as GSAP core", (usage) => { + const html = ``; + expect(htmlHasGsapCore(html)).toBe(false); + }); + + it("reads core from its library body", () => { + const body = `function S(t,e){console.warn("Invalid property",t,"set to",e,"Missing plugin? gsap.registerPlugin()")}`; + expect(htmlHasGsapCore(``)).toBe(true); + }); + + it("ignores GSAP core inside a