diff --git a/packages/core/src/lint/rules/gsap.test.ts b/packages/core/src/lint/rules/gsap.test.ts index e5cc24e9a..a4a1a59c8 100644 --- a/packages/core/src/lint/rules/gsap.test.ts +++ b/packages/core/src/lint/rules/gsap.test.ts @@ -254,4 +254,59 @@ describe("GSAP rules", () => { const finding = result.findings.find((f) => f.code === "missing_gsap_script"); expect(finding).toBeUndefined(); }); + + it("does not report missing_gsap_script when GSAP is bundled inline", () => { + // Simulate a large inline GSAP bundle (>5KB) with GreenSock marker + const fakeGsapLib = "/* GreenSock GSAP */" + " ".repeat(6000); + const html = ` +
+ + + +`; + const result = lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "missing_gsap_script"); + expect(finding).toBeUndefined(); + }); + + it("does not report missing_gsap_script when producer inlined CDN script", () => { + const html = ` + + + + +`; + const result = lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "missing_gsap_script"); + expect(finding).toBeUndefined(); + }); + + it("still reports missing_gsap_script for small inline scripts that use but don't bundle GSAP", () => { + const html = ` + + + +`; + const result = lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "missing_gsap_script"); + expect(finding).toBeDefined(); + expect(finding?.severity).toBe("error"); + }); }); diff --git a/packages/core/src/lint/rules/gsap.ts b/packages/core/src/lint/rules/gsap.ts index c2f0d9aa2..bab3017b8 100644 --- a/packages/core/src/lint/rules/gsap.ts +++ b/packages/core/src/lint/rules/gsap.ts @@ -364,8 +364,20 @@ export const gsapRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [ /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 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)), + ); - if (!usesGsap || hasGsapScript) return []; + if (!usesGsap || hasGsapScript || hasInlineGsap) return []; return [ { code: "missing_gsap_script", diff --git a/packages/producer/src/services/fileServer.ts b/packages/producer/src/services/fileServer.ts index 66568fd98..9f12d3dad 100644 --- a/packages/producer/src/services/fileServer.ts +++ b/packages/producer/src/services/fileServer.ts @@ -301,6 +301,9 @@ export function createFileServer(options: FileServerOptions): Promise
`;
+ const result = collectExternalAssets(html, projectDir);
+ expect(result.externalAssets.size).toBe(0);
+ expect(result.html).toBe(html); // unchanged
+ });
+
+ it("collects and rewrites assets outside projectDir via src attribute", () => {
+ const html = `
`;
+ const result = collectExternalAssets(html, projectDir);
+ expect(result.externalAssets.size).toBe(1);
+
+ const [safeKey, absPath] = [...result.externalAssets.entries()][0]!;
+ expect(safeKey).toContain("hf-ext/");
+ expect(safeKey).toContain("external/hero.png");
+ expect(absPath).toBe(join(externalDir, "hero.png"));
+ expect(result.html).toContain(safeKey);
+ expect(result.html).not.toContain("../external/hero.png");
+ });
+
+ it("collects and rewrites CSS url() references outside projectDir", () => {
+ const html = ``;
+ const result = collectExternalAssets(html, projectDir);
+ expect(result.externalAssets.size).toBe(1);
+ expect(result.html).toContain("hf-ext/");
+ expect(result.html).not.toContain("../external/hero.png");
+ });
+
+ it("collects and rewrites inline style url() references", () => {
+ const html = ``;
+ const result = collectExternalAssets(html, projectDir);
+ expect(result.externalAssets.size).toBe(1);
+ expect(result.html).toContain("hf-ext/");
+ });
+
+ it("skips http/https URLs", () => {
+ const html = `
`;
+ const result = collectExternalAssets(html, projectDir);
+ expect(result.externalAssets.size).toBe(0);
+ });
+
+ it("skips data: URIs", () => {
+ const html = `
`;
+ const result = collectExternalAssets(html, projectDir);
+ expect(result.externalAssets.size).toBe(0);
+ });
+
+ it("deduplicates multiple references to the same external file", () => {
+ const html = `
+
+
`;
+ const result = collectExternalAssets(html, projectDir);
+ // Same file referenced 3 times, but Map deduplicates
+ expect(result.externalAssets.size).toBe(1);
+ });
+
+ it("handles paths with .. that resolve back into projectDir", () => {
+ // projectDir/subdir/../logo.png = projectDir/logo.png (inside project)
+ mkdirSync(join(projectDir, "subdir"), { recursive: true });
+ const html = `
`;
+ const result = collectExternalAssets(html, projectDir);
+ expect(result.externalAssets.size).toBe(0); // stays inside projectDir
+ });
+
+ it("collects multiple different external assets", () => {
+ const html = `
+
+
+ `;
+ const result = collectExternalAssets(html, projectDir);
+ expect(result.externalAssets.size).toBe(2);
+ });
+});
+
+// ── inlineExternalScripts ──────────────────────────────────────────────────
+
+describe("inlineExternalScripts", () => {
+ it("returns HTML unchanged when no external scripts exist", async () => {
+ const html = ``;
+ const result = await inlineExternalScripts(html);
+ expect(result).toBe(html);
+ });
+
+ it("skips local script src (not http)", async () => {
+ const html = ``;
+ const result = await inlineExternalScripts(html);
+ expect(result).toBe(html);
+ });
+
+ it("inlines a CDN script on successful fetch", async () => {
+ const originalFetch = globalThis.fetch;
+ globalThis.fetch = mock(async () => new Response("var gsap = {};", { status: 200 })) as any;
+
+ try {
+ const html = ``;
+ const result = await inlineExternalScripts(html);
+ expect(result).toContain("/* inlined: https://cdn.example.com/gsap.min.js */");
+ expect(result).toContain("var gsap = {};");
+ expect(result).not.toContain('src="https://cdn.example.com/gsap.min.js"');
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+ });
+
+ it("escapes {
+ const originalFetch = globalThis.fetch;
+ globalThis.fetch = mock(
+ async () => new Response('var x = "";', { status: 200 }),
+ ) as any;
+
+ try {
+ const html = ``;
+ const result = await inlineExternalScripts(html);
+ // Should escape ");
+ expect(result).toContain("<\\/script");
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+ });
+
+ it("warns but keeps original tag when fetch fails", async () => {
+ const originalFetch = globalThis.fetch;
+ globalThis.fetch = mock(async () => {
+ throw new Error("Network error");
+ }) as any;
+
+ try {
+ const html = ``;
+ const result = await inlineExternalScripts(html);
+ // Original script tag should remain since download failed
+ expect(result).toContain('src="https://cdn.example.com/gsap.min.js"');
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+ });
+
+ it("handles multiple CDN scripts with mixed success/failure", async () => {
+ const originalFetch = globalThis.fetch;
+ globalThis.fetch = mock(async (url: string) => {
+ if (url.includes("gsap")) {
+ return new Response("var gsap = {};", { status: 200 });
+ }
+ throw new Error("404");
+ }) as any;
+
+ try {
+ const html = `
+
+
+ `;
+ const result = await inlineExternalScripts(html);
+ // GSAP should be inlined
+ expect(result).toContain("var gsap = {};");
+ // Lottie should remain as original tag
+ expect(result).toContain('src="https://cdn.example.com/lottie.min.js"');
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+ });
+
+ it("handles duplicate CDN URLs (same script referenced twice)", async () => {
+ const originalFetch = globalThis.fetch;
+ let fetchCount = 0;
+ globalThis.fetch = mock(async () => {
+ fetchCount++;
+ return new Response("var gsap = {};", { status: 200 });
+ }) as any;
+
+ try {
+ const html = `
+
+
+ `;
+ const result = await inlineExternalScripts(html);
+ // Both should be found, both fetched
+ expect(fetchCount).toBe(2);
+ // At least one should be inlined (regex replaces first occurrence)
+ expect(result).toContain("var gsap = {};");
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+ });
+});
diff --git a/packages/producer/src/services/htmlCompiler.ts b/packages/producer/src/services/htmlCompiler.ts
index ba242b94d..7ac6a0b53 100644
--- a/packages/producer/src/services/htmlCompiler.ts
+++ b/packages/producer/src/services/htmlCompiler.ts
@@ -41,6 +41,8 @@ export interface CompiledComposition {
videos: VideoElement[];
audios: AudioElement[];
unresolvedCompositions: UnresolvedElement[];
+ /** Assets that resolve outside projectDir. Keys are the path used in HTML, values are absolute filesystem paths. */
+ externalAssets: Map