mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 17:27:09 +00:00
fix(studio-server): motion paths animate when gsap has a query string or loads deferred (#5007)
* fix(studio-server): motion paths animate when gsap has a query string or loads deferred Studio puts MotionPathPlugin right after the composition gsap tag. A gsap URL with a query string or hash was not recognised, so the plugin went to the head and ran before gsap; a deferred gsap kept the plugin undeferred, with the same result. The tag is now found with a query or hash, and the plugin copies its type and defer, read with the HTML parser the server already uses. * fix(studio-server): finding the gsap tag stays fast on any preview HTML The gsap tag pattern from the previous commit could backtrack polynomially on long runs of gsap-like src text. The preview now walks script tags with index searches, decides gsap core from the URL path, reads attributes case-insensitively, and skips tags inside comments and templates. * refactor(studio-server): the gsap tag scan is small single-purpose steps with no whitespace regexes Splits the scan so each step has one job (walk tags, read one script tag, find its closing tag, skip a comment or template), and skips whitespace by character instead of with \s* patterns, which code scanning reads as polynomial. * fix(studio-server): the gsap tag search reuses the core HTML scanner, so style blocks and odd comments cannot hide gsap The hand-written walk missed raw-text bodies, empty comments and a comment opener inside an attribute value; any of those before the gsap tag sent the plugin to the head again. findStartTags already skips all of them and is what lazyPreviewImages pairs with the parsed document; the preview now does the same.
This commit is contained in:
@@ -292,6 +292,103 @@ describe("registerPreviewRoutes", () => {
|
||||
expect(plugin?.getAttribute("type")).toBe(AFTER_FONTS_SCRIPT_TYPE);
|
||||
});
|
||||
|
||||
async function previewScriptSrcs(bodyGsapTag: string) {
|
||||
const projectDir = createProjectDir();
|
||||
writeFileSync(
|
||||
join(projectDir, "index.html"),
|
||||
`<!doctype html><html><head></head><body><div id="card" class="clip"></div>
|
||||
${bodyGsapTag}
|
||||
<script>
|
||||
window.__timelines = { index: gsap.timeline({ paused: true }).to("#card", { motionPath: { path: [{ x: 100, y: 50 }] } }) };
|
||||
</script>
|
||||
</body></html>`,
|
||||
);
|
||||
const app = new Hono();
|
||||
registerPreviewRoutes(app, createAdapter(projectDir));
|
||||
const html = await (await app.request("http://localhost/projects/demo/preview")).text();
|
||||
return [...parseHTML(html).document.querySelectorAll("script[src]")];
|
||||
}
|
||||
|
||||
it.each([
|
||||
["a query string", "https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js?v=1"],
|
||||
["a hash", "https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.js#core"],
|
||||
])("puts the MotionPathPlugin right after a body gsap whose URL has %s", async (_, src) => {
|
||||
const scripts = await previewScriptSrcs(`<script src="${src}"></script>`);
|
||||
const gsapAt = scripts.findIndex((el) => el.getAttribute("src") === src);
|
||||
expect(gsapAt).toBeGreaterThan(-1);
|
||||
expect(scripts[gsapAt + 1]?.getAttribute("src")).toContain(
|
||||
"gsap@3/dist/MotionPathPlugin.min.js",
|
||||
);
|
||||
});
|
||||
|
||||
it("defers the MotionPathPlugin with a deferred body gsap, so it runs after gsap", async () => {
|
||||
const scripts = await previewScriptSrcs(
|
||||
`<script defer src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>`,
|
||||
);
|
||||
const gsapAt = scripts.findIndex((el) => el.getAttribute("src")?.endsWith("/gsap.min.js"));
|
||||
const plugin = scripts[gsapAt + 1];
|
||||
expect(plugin?.getAttribute("src")).toContain("MotionPathPlugin.min.js");
|
||||
expect(plugin?.hasAttribute("defer")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not read defer out of another attribute value", async () => {
|
||||
const scripts = await previewScriptSrcs(
|
||||
`<script data-note="do not defer this" src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>`,
|
||||
);
|
||||
const plugin = scripts.find((el) => el.getAttribute("src")?.includes("MotionPathPlugin"));
|
||||
expect(plugin?.hasAttribute("defer")).toBe(false);
|
||||
});
|
||||
|
||||
it("finds no gsap tag in a long run of gsap-like src text without stalling", async () => {
|
||||
const scripts = await previewScriptSrcs(`<script src="${"/gsap.js#".repeat(50_000)}`);
|
||||
expect(scripts.some((el) => el.getAttribute("src")?.includes("MotionPathPlugin"))).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a style block", `<style>/* <script src="x"></script> */ .a { color: red; }</style>`],
|
||||
["an empty comment", `<!--> <p>text</p>`],
|
||||
["a comment start inside an attribute", `<div title="<!--"></div>`],
|
||||
])("still finds the live gsap tag after %s", async (_, before) => {
|
||||
const live = "https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js";
|
||||
const scripts = await previewScriptSrcs(`${before}\n <script src="${live}"></script>`);
|
||||
const liveAt = scripts.findIndex((el) => el.getAttribute("src") === live);
|
||||
expect(scripts[liveAt + 1]?.getAttribute("src")).toContain("MotionPathPlugin");
|
||||
});
|
||||
|
||||
it("ignores a gsap-named file that is not gsap core", async () => {
|
||||
const scripts = await previewScriptSrcs(
|
||||
`<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js.map"></script>`,
|
||||
);
|
||||
const plugin = scripts.findIndex((el) => el.getAttribute("src")?.includes("MotionPathPlugin"));
|
||||
const map = scripts.findIndex((el) => el.getAttribute("src")?.endsWith(".map"));
|
||||
expect(plugin).toBeLessThan(map);
|
||||
});
|
||||
|
||||
it("reads DEFER and TYPE written in capitals", async () => {
|
||||
const scripts = await previewScriptSrcs(
|
||||
`<script TYPE="text/javascript" DEFER SRC="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>`,
|
||||
);
|
||||
const plugin = scripts.find((el) => el.getAttribute("src")?.includes("MotionPathPlugin"));
|
||||
expect(plugin?.hasAttribute("defer")).toBe(true);
|
||||
expect(plugin?.getAttribute("type")).toBe("text/javascript");
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"a comment",
|
||||
`<!-- a > b <script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script> -->`,
|
||||
],
|
||||
[
|
||||
"a template",
|
||||
`<template><template></template><script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script></template>`,
|
||||
],
|
||||
])("skips a gsap tag inside %s and follows the live one", async (_, inert) => {
|
||||
const live = "https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js?live";
|
||||
const scripts = await previewScriptSrcs(`${inert}\n <script src="${live}"></script>`);
|
||||
const liveAt = scripts.findIndex((el) => el.getAttribute("src") === live);
|
||||
expect(scripts[liveAt + 1]?.getAttribute("src")).toContain("MotionPathPlugin");
|
||||
});
|
||||
|
||||
it("does NOT inject MotionPathPlugin when the composition has no motionPath", async () => {
|
||||
const projectDir = createProjectDir();
|
||||
writeFileSync(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Hono } from "hono";
|
||||
import { parseHTML } from "linkedom";
|
||||
import { createReadStream, existsSync, readFileSync, statSync } from "node:fs";
|
||||
import { Readable } from "node:stream";
|
||||
import { join, resolve } from "node:path";
|
||||
@@ -12,7 +13,7 @@ import {
|
||||
} from "@hyperframes/core/compiler";
|
||||
import { STUDIO_PREVIEW_MARK_META } from "@hyperframes/core/studio-preview-mark";
|
||||
import { gsapCdnDist, motionPathPluginUrl } from "@hyperframes/core/gsap-cdn";
|
||||
import { injectTagsAtHeadStart } from "@hyperframes/core/compiler/html-document";
|
||||
import { findStartTags, injectTagsAtHeadStart } from "@hyperframes/core/compiler/html-document";
|
||||
import { isWithinProjectRoot } from "@hyperframes/parsers/asset-resolution";
|
||||
import type { ResolvedProject, StudioApiAdapter } from "../types.js";
|
||||
import { isProjectRootMissing, resolveWithinProject } from "../helpers/safePath.js";
|
||||
@@ -154,18 +155,55 @@ function injectMotionPathPluginIfNeeded(html: string): string {
|
||||
// core gsap script — which often lives at body-end, not <head>. Insert it
|
||||
// directly after the gsap script tag; only fall back to <head> if none is found
|
||||
// (e.g. gsap is inlined).
|
||||
const gsapScript = /<script\b[^>]*\bsrc=["'][^"']*\/gsap(\.min)?\.js["'][^>]*>\s*<\/script>/i;
|
||||
const match = html.match(gsapScript);
|
||||
if (match) {
|
||||
const version = match[0].match(/gsap@([\d.]+)/)?.[1];
|
||||
const gsapTagType = match[0].match(/\stype=("[^"]*"|'[^']*')/i)?.[0] ?? "";
|
||||
const pluginTag = `<script${gsapTagType} src="${motionPathPluginUrl(version)}"></script>`;
|
||||
const end = html.indexOf(match[0]) + match[0].length;
|
||||
return html.slice(0, end) + "\n" + pluginTag + html.slice(end);
|
||||
const gsap = findGsapCoreScript(html);
|
||||
if (gsap) {
|
||||
const version = gsap.src.match(/gsap@([\d.]+)/)?.[1];
|
||||
const ordering = `${gsap.type ? ` type="${gsap.type}"` : ""}${gsap.defer ? " defer" : ""}`;
|
||||
const pluginTag = `<script${ordering} src="${motionPathPluginUrl(version)}"></script>`;
|
||||
return html.slice(0, gsap.end) + "\n" + pluginTag + html.slice(gsap.end);
|
||||
}
|
||||
return injectScriptTagIntoHead(html, GSAP_MOTION_PATH_CDN_SCRIPT);
|
||||
}
|
||||
|
||||
function isGsapCoreUrl(src: string): boolean {
|
||||
const base = "http://localhost/";
|
||||
return URL.canParse(src, base) && /\/gsap(\.min)?\.js$/i.test(new URL(src, base).pathname);
|
||||
}
|
||||
|
||||
type GsapCoreScript = { src: string; type: string | null; defer: boolean; end: number };
|
||||
|
||||
function findGsapCoreScript(html: string): GsapCoreScript | null {
|
||||
const scripts = [...parseHTML(html).document.querySelectorAll("script")];
|
||||
// The scanner skips comments, raw text and templates, so its `<script` offsets line up with the DOM's scripts.
|
||||
const starts = findStartTags(html, "script");
|
||||
if (starts.length !== scripts.length) return null;
|
||||
const at = scripts.findIndex((script) =>
|
||||
isGsapCoreUrl(lowerCaseAttributes(script).get("src") ?? ""),
|
||||
);
|
||||
if (at === -1) return null;
|
||||
const attributes = lowerCaseAttributes(scripts[at]!);
|
||||
const end = scriptCloseEnd(html, starts[at]!);
|
||||
if (end === 0) return null;
|
||||
return {
|
||||
src: attributes.get("src")!,
|
||||
type: attributes.get("type") ?? null,
|
||||
defer: attributes.has("defer"),
|
||||
end,
|
||||
};
|
||||
}
|
||||
|
||||
function lowerCaseAttributes(el: Element): Map<string, string> {
|
||||
return new Map([...el.attributes].map((a) => [a.name.toLowerCase(), a.value]));
|
||||
}
|
||||
|
||||
/** Index just past the `</script>` that closes the script starting at `start`; 0 when it never closes. */
|
||||
function scriptCloseEnd(html: string, start: number): number {
|
||||
const close = /<\/script/gi;
|
||||
close.lastIndex = start;
|
||||
const closeAt = close.exec(html)?.index;
|
||||
return closeAt === undefined ? 0 : html.indexOf(">", closeAt) + 1;
|
||||
}
|
||||
|
||||
function injectStudioMotionDependencies(html: string, manifestContent: string): string {
|
||||
const manifest = parseStudioMotionManifestContent(manifestContent);
|
||||
if (!manifest.hasMotion) return html;
|
||||
|
||||
Reference in New Issue
Block a user