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 ", () => {
+ const src = "https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js";
+ expect(htmlHasGsapCore(``)).toBe(false);
+ });
+});
+
+describe("insertGsapCoreBeforeHeadPlugin", () => {
+ it.each([
+ ["a plugin src", ``],
+ ["a local plugin", ``],
+ ["an inlined plugin", ``],
+ [
+ "a plugin license banner",
+ ``,
+ ],
+ ])("puts core before %s in the head", (_name, plugin) => {
+ const html = `${plugin}`;
+ const out = insertGsapCoreBeforeHeadPlugin(html, CORE) ?? "";
+ expect(out.indexOf(CORE)).toBeGreaterThan(out.indexOf("var a;"));
+ expect(out.indexOf(CORE)).toBeLessThan(out.indexOf(plugin));
+ });
+
+ it("works on a bare head fragment", () => {
+ const out = insertGsapCoreBeforeHeadPlugin(``, CORE) ?? "";
+ expect(out.startsWith(CORE)).toBe(true);
+ });
+
+ it("leaves the head alone when the only plugin is in the body or a template", () => {
+ const tag = ``;
+ expect(
+ insertGsapCoreBeforeHeadPlugin(`${tag}`, CORE),
+ ).toBeNull();
+ expect(
+ insertGsapCoreBeforeHeadPlugin(`${tag}`, CORE),
+ ).toBeNull();
+ });
+});
diff --git a/packages/studio-server/src/helpers/gsapCoreScript.ts b/packages/studio-server/src/helpers/gsapCoreScript.ts
new file mode 100644
index 0000000000..d8acf3b04a
--- /dev/null
+++ b/packages/studio-server/src/helpers/gsapCoreScript.ts
@@ -0,0 +1,57 @@
+import { findStartTags, insertBeforeCloseTag } from "@hyperframes/core/compiler/html-document";
+
+const URL_BASE = "http://localhost/";
+
+function urlPath(src: string): string | null {
+ return URL.canParse(src, URL_BASE) ? new URL(src, URL_BASE).pathname : null;
+}
+
+export function isGsapCoreUrl(src: string): boolean {
+ return /\/gsap(-\d+(\.\d+)*)?(\.min)?\.js$/i.test(urlPath(src) ?? "");
+}
+
+const GSAP_PLUGIN_FILE =
+ /\/(\w+Plugin|CustomBounce|CustomEase|CustomWiggle|Draggable|EasePack|Flip|GSDevTools|MotionPathHelper|Observer|ScrollSmoother|ScrollTrigger|SplitText)(\.min)?\.js$/i;
+
+function isGsapPluginUrl(src: string): boolean {
+ const path = urlPath(src);
+ if (!path || isGsapCoreUrl(src)) return false;
+ return GSAP_PLUGIN_FILE.test(path) || /\/gsap(@[^/]*)?\/(.+\/)?[^/]+\.js$/i.test(path);
+}
+
+const TEMPLATE_ELEMENT = /]*>[\s\S]*?<\/template>/gi;
+const SCRIPT_SRC = / {
expect(html).toContain(``);
});
+ it.each(["", ""])(
+ "loads GSAP core before a project head that has only a GSAP plugin (%#)",
+ (usage) => {
+ const plugin = ``;
+ const dir = makeTempProject({
+ "index.html": `${plugin}${usage}`,
+ "compositions/scene.html": ``,
+ });
+ const html = buildSubCompositionHtml(dir, "compositions/scene.html", "/api/runtime.js") ?? "";
+ const core = html.indexOf(``);
+ expect(core).toBeGreaterThanOrEqual(0);
+ expect(core).toBeLessThan(html.indexOf(plugin));
+ },
+ );
+
it("adds the preview runtime even when the project head's script mentions a runtime file", () => {
const dir = makeTempProject({
"index.html": ``,
diff --git a/packages/studio-server/src/helpers/subComposition.ts b/packages/studio-server/src/helpers/subComposition.ts
index e7e752cf83..6ed4600cf1 100644
--- a/packages/studio-server/src/helpers/subComposition.ts
+++ b/packages/studio-server/src/helpers/subComposition.ts
@@ -13,6 +13,7 @@ import {
} from "@hyperframes/core/compiler";
import { isFullHtmlDocument } from "@hyperframes/core/compiler/html-document";
import { gsapCdnDist } from "@hyperframes/core/gsap-cdn";
+import { htmlHasGsapCore, insertGsapCoreBeforeHeadPlugin } from "./gsapCoreScript.js";
/**
* Rewrite relative asset paths in a parsed DOM tree. Shared across all
@@ -353,8 +354,9 @@ export function buildSubCompositionHtml(
headContent += `\n`;
// Fallback: if no index.html head was found, add minimal deps
- if (!headContent.includes("gsap")) {
- headContent += `\n`;
+ if (!htmlHasGsapCore(headContent)) {
+ const core = ``;
+ headContent = insertGsapCoreBeforeHeadPlugin(headContent, core) ?? `${headContent}\n${core}`;
}
const htmlOpen = htmlAttrs ? `` : "";
diff --git a/packages/studio-server/src/routes/preview.test.ts b/packages/studio-server/src/routes/preview.test.ts
index 8bcf3991d8..5441dfa5a5 100644
--- a/packages/studio-server/src/routes/preview.test.ts
+++ b/packages/studio-server/src/routes/preview.test.ts
@@ -359,6 +359,67 @@ describe("registerPreviewRoutes", () => {
expect(html.indexOf("CustomEase.min.js")).toBeLessThan(html.indexOf("__hfStudioMotionApply"));
});
+ it("injects GSAP core before the MotionPathPlugin when Studio motion and a motionPath share a page with no GSAP", async () => {
+ const projectDir = createProjectDir();
+ writeFileSync(
+ join(projectDir, "index.html"),
+ `
+
+ `,
+ );
+ const manifestDir = join(projectDir, ".hyperframes");
+ mkdirSync(manifestDir, { recursive: true });
+ writeFileSync(
+ join(manifestDir, "studio-motion.json"),
+ `{"version":1,"motions":[{"kind":"gsap-motion","target":{"sourceFile":"index.html","id":"card"},"start":0,"duration":1,"ease":"power2.out","from":{"y":32},"to":{"y":0}}]}`,
+ );
+ const app = new Hono();
+ registerPreviewRoutes(app, createAdapter(projectDir));
+
+ const response = await app.request("http://localhost/projects/demo/preview");
+ const srcs = [...parseHTML(await response.text()).document.querySelectorAll("script[src]")].map(
+ (el) => el.getAttribute("src") ?? "",
+ );
+
+ expect(response.status).toBe(200);
+ const core = srcs.findIndex((src) => src.endsWith("/gsap.min.js"));
+ const plugin = srcs.findIndex((src) => src.endsWith("/MotionPathPlugin.min.js"));
+ expect(core).toBeGreaterThanOrEqual(0);
+ expect(plugin).toBeGreaterThan(core);
+ });
+
+ it.each([
+ ["", ""],
+ ["", ""],
+ ["", ""],
+ ])("injects GSAP core before an authored head MotionPathPlugin (%#)", async (before, after) => {
+ const projectDir = createProjectDir();
+ writeFileSync(
+ join(projectDir, "index.html"),
+ `${before}${after}`,
+ );
+ const manifestDir = join(projectDir, ".hyperframes");
+ mkdirSync(manifestDir, { recursive: true });
+ writeFileSync(
+ join(manifestDir, "studio-motion.json"),
+ `{"version":1,"motions":[{"kind":"gsap-motion","target":{"sourceFile":"index.html","id":"card"},"start":0,"duration":1,"ease":"power2.out","from":{"y":32},"to":{"y":0}}]}`,
+ );
+ const app = new Hono();
+ registerPreviewRoutes(app, createAdapter(projectDir));
+
+ const response = await app.request("http://localhost/projects/demo/preview");
+ const srcs = [...parseHTML(await response.text()).document.querySelectorAll("script[src]")].map(
+ (el) => el.getAttribute("src") ?? "",
+ );
+
+ expect(response.status).toBe(200);
+ const core = srcs.findIndex((src) => src.endsWith("/gsap.min.js"));
+ expect(core).toBeGreaterThanOrEqual(0);
+ expect(srcs.findIndex((src) => src.endsWith("/MotionPathPlugin.min.js"))).toBeGreaterThan(core);
+ });
+
it("injects the GSAP MotionPathPlugin when the composition uses a motionPath", async () => {
const projectDir = createProjectDir();
writeFileSync(
diff --git a/packages/studio-server/src/routes/preview.ts b/packages/studio-server/src/routes/preview.ts
index 8a5581c0b3..068b1bc3b8 100644
--- a/packages/studio-server/src/routes/preview.ts
+++ b/packages/studio-server/src/routes/preview.ts
@@ -35,6 +35,11 @@ import {
} from "../helpers/safePath.js";
import { getMimeType } from "../helpers/mime.js";
import { buildSubCompositionHtml, hasBaseElement } from "../helpers/subComposition.js";
+import {
+ htmlHasGsapCore,
+ insertGsapCoreBeforeHeadPlugin,
+ isGsapCoreUrl,
+} from "../helpers/gsapCoreScript.js";
import {
createProjectSignature,
resolveProjectAndSignature,
@@ -138,18 +143,6 @@ function injectScriptTagIntoHead(html: string, scriptTag: string): string {
return insertBeforeCloseTag(html, "head", `${scriptTag}\n`) ?? `${scriptTag}\n${html}`;
}
-function htmlHasGsap(html: string): boolean {
- // Only match GSAP references outside elements — scripts inside
- // templates are inert when cloned and don't make GSAP globally available.
- const outsideTemplates = html.replace(/]*>[\s\S]*?<\/template>/gi, "");
- return (
- /`;
- return insertBeforeCloseTag(withDependencies, "body", tag) ?? `${withDependencies}${tag}`;
+ return insertBeforeCloseTag(html, "body", tag) ?? `${html}${tag}`;
}
const GSAP_FALLBACK_ATTR = "data-hf-gsap-fallback";
@@ -362,24 +352,27 @@ function injectStudioPreviewAugmentations(
lazyPreviewImages(html),
`${EARLY_ERRORS_SCRIPT}`,
);
- return injectStudioMotionScript(
- injectMotionPathPluginIfNeeded(
- injectGsapCdnFallback(
- injectProjectSignature(
- injectTagsAtHeadStart(
- marked,
- ``,
- ),
- resolveProjectSignature(adapter, projectDir),
- ),
- options.gsapBaseUrl,
+ const withFallback = injectGsapCdnFallback(
+ injectProjectSignature(
+ injectTagsAtHeadStart(
+ marked,
+ ``,
),
- options.gsapBaseUrl,
+ resolveProjectSignature(adapter, projectDir),
),
- projectDir,
- activeCompositionPath,
options.gsapBaseUrl,
);
+ const studioMotion = readStudioMotion(projectDir, activeCompositionPath);
+ // Studio motion's GSAP core goes in first, so a MotionPathPlugin injected next lands after it.
+ const withMotionDependencies = studioMotion
+ ? injectStudioMotionDependencies(
+ withFallback,
+ studioMotion.manifestContent,
+ options.gsapBaseUrl,
+ )
+ : withFallback;
+ const withPlugin = injectMotionPathPluginIfNeeded(withMotionDependencies, options.gsapBaseUrl);
+ return studioMotion ? injectStudioMotionScript(withPlugin, studioMotion.script) : withPlugin;
}
async function transformPreviewHtml(