feat(studio): GSAP tween editing in Design panel (#1102)

* feat(studio): GSAP tween editing in Design panel

Add a GSAP animation editor to the studio Design panel: select an element,
view and edit its tweens (properties, easing, timing), add/delete animations,
and drag custom bezier speed curves — all persisted back to the composition
HTML. Gated behind VITE_STUDIO_ENABLE_GSAP_PANEL.

Parsing of existing GSAP source now uses a recast + Babel AST parser instead of
regex, giving scope resolution, stable tween IDs, and round-trip preservation of
extras and unresolved raw values.

recast compiles to CommonJS that calls require("fs"), which breaks browser and
Vite SSR bundles. To contain it, @hyperframes/core is split into an isomorphic
layer and a Node-only AST layer:

- gsapSerialize.ts holds the recast-free helpers (serialization, keyframe
  conversion, validation, shared types). htmlParser.ts is now fully isomorphic.
- parseGsapScript and the script-mutation helpers live in gsapParser.ts,
  reachable only via the @hyperframes/core/gsap-parser subpath, loaded
  server-side by the studio-api mutation routes and the linter via dynamic
  import (recast stays external under SSR).
- The barrel and the gsap-constants subpath are recast-free, so studio browser
  bundles never trace recast.

Adds AST parser unit + stress coverage and e2e helpers for the panel.

* fix(lint): await async lintHyperframeHtml in all callers

lintHyperframeHtml became async (gsap rules use dynamic import)
but lintProject and check-hyperframe-static weren't awaiting it,
causing typecheck failures and runtime crashes in CI.

Also wire LintRule type in gsap rules to fix fallow unused-type
finding, and suppress render.ts exported-for-tests symbols.
This commit is contained in:
Miguel Ángel
2026-05-28 19:16:34 -04:00
committed by GitHub
parent e16f916448
commit fb2e21090f
61 changed files with 4354 additions and 1128 deletions
@@ -0,0 +1,48 @@
/**
* GSAP property and ease constants.
*
* Extracted into a standalone module so browser code can import them
* without pulling in gsapParser (which depends on recast / @babel/parser).
*/
export const SUPPORTED_PROPS = [
"opacity",
"visibility",
"x",
"y",
"scale",
"scaleX",
"scaleY",
"rotation",
"autoAlpha",
"width",
"height",
];
export const SUPPORTED_EASES = [
"none",
"power1.in",
"power1.out",
"power1.inOut",
"power2.in",
"power2.out",
"power2.inOut",
"power3.in",
"power3.out",
"power3.inOut",
"power4.in",
"power4.out",
"power4.inOut",
"back.in",
"back.out",
"back.inOut",
"elastic.in",
"elastic.out",
"elastic.inOut",
"bounce.in",
"bounce.out",
"bounce.inOut",
"expo.in",
"expo.out",
"expo.inOut",
];
@@ -0,0 +1,952 @@
import { describe, it, expect } from "vitest";
import {
parseGsapScript,
serializeGsapAnimations,
updateAnimationInScript,
addAnimationToScript,
removeAnimationFromScript,
} from "./gsapParser.js";
import type { ParsedGsap } from "./gsapParser.js";
// ── Helpers ────────────────────────────────────────────────────────────────
/** Assert a parse completed without crashing and returned the safe-default shape. */
function expectSafeDefault(result: ParsedGsap) {
expect(result).toBeDefined();
expect(Array.isArray(result.animations)).toBe(true);
expect(typeof result.timelineVar).toBe("string");
}
/** Parse, serialize, re-parse, and assert structural equality of the animation IR. */
function assertRoundTrip(script: string) {
const parsed1 = parseGsapScript(script);
expect(parsed1.animations.length).toBeGreaterThan(0);
const serialized = serializeGsapAnimations(parsed1.animations, parsed1.timelineVar, {
preamble: parsed1.preamble,
postamble: parsed1.postamble,
});
const parsed2 = parseGsapScript(serialized);
expect(parsed2.animations.length).toBe(parsed1.animations.length);
for (let i = 0; i < parsed1.animations.length; i++) {
const a = parsed1.animations[i];
const b = parsed2.animations[i];
expect(b.targetSelector).toBe(a.targetSelector);
expect(b.method).toBe(a.method);
expect(b.position).toEqual(a.position);
expect(b.duration).toEqual(a.duration);
expect(b.ease).toEqual(a.ease);
// Properties: numeric values must match; __raw values may re-serialize differently
for (const [key, val] of Object.entries(a.properties)) {
if (typeof val === "number") {
expect(b.properties[key]).toBe(val);
} else if (typeof val === "string" && val.startsWith("__raw:")) {
// Raw values survive in some form — just confirm the key exists
expect(b.properties).toHaveProperty(key);
}
}
// Extras survive
if (a.extras) {
expect(b.extras).toBeDefined();
for (const key of Object.keys(a.extras)) {
expect(b.extras).toHaveProperty(key);
}
}
}
}
// ── 1. Malformed Scripts ───────────────────────────────────────────────────
describe("1. Malformed scripts", () => {
const cases = [
{
name: "unclosed brace",
script: "const tl = gsap.timeline({ paused: true }); tl.to('#a', { x: 1",
},
{
name: "unclosed parenthesis",
script: "const tl = gsap.timeline({ paused: true }); tl.to('#a', { x: 1 }, 0",
},
{ name: "random garbage", script: "@@@ not javascript at all ~~~" },
{ name: "partial assignment", script: "const tl =" },
{
name: "missing semicolons everywhere",
script:
"const tl = gsap.timeline({ paused: true })\ntl.to('#a', { x: 1 }, 0)\ntl.to('#b', { y: 2 }, 1)",
},
{
name: "double commas",
script: 'const tl = gsap.timeline({ paused: true }); tl.to("#a",, { x: 1 }, 0);',
},
{ name: "HTML mixed in", script: "<div>hello</div>\nconst tl = gsap.timeline();" },
{ name: "only opening brace", script: "{" },
{ name: "only closing brace", script: "}" },
{ name: "null byte", script: "const tl = gsap.timeline();\x00 tl.to('#a', { x: 1 }, 0);" },
];
for (const { name, script } of cases) {
it(`does not crash on: ${name}`, () => {
const result = parseGsapScript(script);
expectSafeDefault(result);
});
it(`mutation functions are safe on: ${name}`, () => {
// Some malformed scripts might parse as valid but empty — mutation safety
// still applies (either noop or a valid transform)
expect(() => updateAnimationInScript(script, "id", { duration: 1 })).not.toThrow();
expect(() =>
addAnimationToScript(script, {
targetSelector: "#el",
method: "to",
position: 0,
properties: { opacity: 1 },
duration: 1,
}),
).not.toThrow();
expect(() => removeAnimationFromScript(script, "id")).not.toThrow();
});
}
it("missing semicolons still parse tweens (ASI)", () => {
const script = `
const tl = gsap.timeline({ paused: true })
tl.to("#a", { x: 100, duration: 0.5 }, 0)
tl.to("#b", { y: 200, duration: 1 }, 1)
`;
const result = parseGsapScript(script);
// Babel handles ASI — these should parse fine
expect(result.animations.length).toBe(2);
});
});
// ── 2. Empty / Minimal Scripts ─────────────────────────────────────────────
describe("2. Empty / minimal scripts", () => {
it("empty string", () => {
const result = parseGsapScript("");
expectSafeDefault(result);
expect(result.animations).toHaveLength(0);
});
it("whitespace only", () => {
const result = parseGsapScript(" \n\t\n ");
expectSafeDefault(result);
expect(result.animations).toHaveLength(0);
});
it("window.__timelines = {} with no tweens", () => {
const script = "window.__timelines = {};";
const result = parseGsapScript(script);
expectSafeDefault(result);
expect(result.animations).toHaveLength(0);
});
it("timeline declaration with no tween calls", () => {
const script = "const tl = gsap.timeline({ paused: true });";
const result = parseGsapScript(script);
expect(result.timelineVar).toBe("tl");
expect(result.animations).toHaveLength(0);
});
it("only comments", () => {
const script = "// this is a comment\n/* block comment */";
const result = parseGsapScript(script);
expectSafeDefault(result);
expect(result.animations).toHaveLength(0);
});
it("tween with only selector and empty vars object", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el", {}, 0);
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(1);
expect(Object.keys(result.animations[0].properties)).toHaveLength(0);
expect(result.animations[0].duration).toBeUndefined();
});
});
// ── 3. Extreme Values ──────────────────────────────────────────────────────
describe("3. Extreme values", () => {
it("very large numbers", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el", { x: 1e10, y: 99999999, duration: 1000000 }, 0);
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(1);
expect(result.animations[0].properties.x).toBe(1e10);
expect(result.animations[0].properties.y).toBe(99999999);
expect(result.animations[0].duration).toBe(1000000);
});
it("very small numbers", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el", { opacity: 0.001, duration: 0.0001 }, 0.00001);
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(1);
expect(result.animations[0].properties.opacity).toBe(0.001);
expect(result.animations[0].duration).toBe(0.0001);
expect(result.animations[0].position).toBeCloseTo(0.00001);
});
it("negative position", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el", { x: 100, duration: 1 }, -5);
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(1);
expect(result.animations[0].position).toBe(-5);
});
it("zero duration", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el", { x: 100, duration: 0 }, 0);
`;
const result = parseGsapScript(script);
expect(result.animations[0].duration).toBe(0);
});
it("NaN-producing division by zero is handled", () => {
const script = `
const ZERO = 0;
const BAD = 100 / ZERO;
const tl = gsap.timeline({ paused: true });
tl.to("#el", { x: BAD, y: 50, duration: 1 }, 0);
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(1);
// Division by zero returns undefined from resolveNode, so BAD is unresolvable
// x should be __raw: or undefined, y should be 50
expect(result.animations[0].properties.y).toBe(50);
// BAD was never bound (division by zero returns undefined), so the reference is raw
const xVal = result.animations[0].properties.x;
expect(typeof xVal === "string" && xVal.startsWith("__raw:")).toBe(true);
});
it("Infinity literal", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el", { x: Infinity, y: 50, duration: 1 }, 0);
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(1);
// Infinity is an Identifier, not a NumericLiteral — should be __raw
const xVal = result.animations[0].properties.x;
expect(typeof xVal === "string" && xVal.startsWith("__raw:")).toBe(true);
expect(result.animations[0].properties.y).toBe(50);
});
});
// ── 4. Unicode in Selectors ────────────────────────────────────────────────
describe("4. Unicode in selectors", () => {
it("Japanese characters in selector", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#日本語", { x: 100, duration: 1 }, 0);
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(1);
expect(result.animations[0].targetSelector).toBe("#日本語");
});
it("emoji in selector", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#rocket-🚀", { opacity: 1, duration: 0.5 }, 0);
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(1);
expect(result.animations[0].targetSelector).toBe("#rocket-🚀");
});
it("Arabic and Cyrillic selectors", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#عربي", { x: 50, duration: 1 }, 0);
tl.to("#кириллица", { y: 100, duration: 1 }, 1);
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(2);
expect(result.animations[0].targetSelector).toBe("#عربي");
expect(result.animations[1].targetSelector).toBe("#кириллица");
});
it("class selector with unicode", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to(".コンポーネント", { scale: 2, duration: 0.5 }, 0);
`;
const result = parseGsapScript(script);
expect(result.animations[0].targetSelector).toBe(".コンポーネント");
});
});
// ── 5. Deeply Nested Objects ───────────────────────────────────────────────
describe("5. Deeply nested objects", () => {
it("complex stagger object preserved in extras", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to(".items", { opacity: 1, duration: 0.5, stagger: { amount: 1, grid: [3, 3], from: "center", axis: "x" } }, 0);
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(1);
expect(result.animations[0].extras).toBeDefined();
expect(result.animations[0].extras!.stagger).toBeDefined();
// stagger should be __raw: containing the nested object source
const stagger = String(result.animations[0].extras!.stagger);
expect(stagger.startsWith("__raw:")).toBe(true);
expect(stagger).toContain("amount");
expect(stagger).toContain("grid");
expect(stagger).toContain("center");
});
it("complex stagger survives round-trip serialization", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to(".items", { opacity: 1, duration: 0.5, stagger: { amount: 1, grid: [3, 3], from: "center", axis: "x" } }, 0);
`;
const parsed = parseGsapScript(script);
const serialized = serializeGsapAnimations(parsed.animations, parsed.timelineVar, {
preamble: parsed.preamble,
postamble: parsed.postamble,
});
expect(serialized).toContain("stagger:");
expect(serialized).toContain("amount");
expect(serialized).toContain("grid");
});
it("nested ease config object (non-string ease)", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el", { x: 100, duration: 1, ease: "back.out(1.7)" }, 0);
`;
const result = parseGsapScript(script);
expect(result.animations[0].ease).toBe("back.out(1.7)");
});
});
// ── 6. Chained Method Calls ────────────────────────────────────────────────
describe("6. Chained method calls", () => {
it("chained tl.to().to().from() — only top-level calls detected", () => {
// Chaining like tl.to(...).to(...) means the second .to() is called on the
// return value of the first .to(), which is the timeline itself. However,
// the parser checks `callee.object.name === timelineVar`, so chained calls
// where the callee.object is a CallExpression (not an Identifier) are skipped.
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#a", { x: 100, duration: 0.5 }, 0).to("#b", { y: 200, duration: 0.5 }, 1).from("#c", { scale: 0, duration: 1 }, 2);
`;
const result = parseGsapScript(script);
// Only the first call in the chain has `tl` as the callee object directly
// The rest are chained on the return value — parser may or may not catch them
expect(result.animations.length).toBeGreaterThanOrEqual(1);
expect(result.animations[0].targetSelector).toBe("#a");
expect(result.animations[0].properties.x).toBe(100);
});
it("separate statements all parse", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#a", { x: 100, duration: 0.5 }, 0);
tl.to("#b", { y: 200, duration: 0.5 }, 1);
tl.from("#c", { scale: 0, duration: 1 }, 2);
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(3);
});
});
// ── 7. Template Literals in Values ─────────────────────────────────────────
describe("7. Template literals in values", () => {
it("template literal with no expressions resolves to string", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el", { x: 50, duration: 1, ease: \`power2.out\` }, 0);
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(1);
expect(result.animations[0].ease).toBe("power2.out");
});
it("template literal with expression becomes __raw", () => {
const script = `
const val = 100;
const tl = gsap.timeline({ paused: true });
tl.to("#el", { x: \`\${val}px\`, y: 50, duration: 1 }, 0);
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(1);
// Template literal with expressions is not resolvable
const xVal = result.animations[0].properties.x;
expect(typeof xVal === "string" && xVal.startsWith("__raw:")).toBe(true);
expect(result.animations[0].properties.y).toBe(50);
});
});
// ── 8. Multiple Scripts in One HTML ────────────────────────────────────────
describe("8. Multiple timelines", () => {
it("two gsap.timeline() calls sets multipleTimelines flag", () => {
const script = `
const tl1 = gsap.timeline({ paused: true });
tl1.to("#a", { x: 100, duration: 1 }, 0);
const tl2 = gsap.timeline({ paused: true });
tl2.to("#b", { y: 200, duration: 1 }, 0);
`;
const result = parseGsapScript(script);
expect(result.multipleTimelines).toBe(true);
// Parser only tracks the first timeline variable
expect(result.timelineVar).toBe("tl1");
// Only tl1 tweens are captured
expect(result.animations.every((a) => a.targetSelector === "#a")).toBe(true);
});
it("two scripts concatenated with same variable name", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#first", { opacity: 1, duration: 0.5 }, 0);
// Second block re-uses tl but creates a new timeline
const tl2 = gsap.timeline({ paused: true });
tl.to("#second", { opacity: 0.5, duration: 1 }, 1);
`;
const result = parseGsapScript(script);
// Both .to() calls use "tl" as the callee object, so both are captured
expect(result.multipleTimelines).toBe(true);
const selectors = result.animations.map((a) => a.targetSelector);
expect(selectors).toContain("#first");
expect(selectors).toContain("#second");
});
});
// ── 9. Comments Everywhere ─────────────────────────────────────────────────
describe("9. Comments everywhere", () => {
it("inline comments inside tween args", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el", { /* fade in */ opacity: 1 /*, y: 200*/, duration: 0.5 }, 0);
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(1);
expect(result.animations[0].properties.opacity).toBe(1);
// y: 200 is commented out, should not appear
expect(result.animations[0].properties).not.toHaveProperty("y");
});
it("line comments between tween calls", () => {
const script = `
const tl = gsap.timeline({ paused: true });
// First animation
tl.set("#el", { opacity: 0 }, 0);
// Second animation
tl.to("#el", { opacity: 1, duration: 1 }, 0.5);
// Done
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(2);
});
it("comment inside selector string (not really a comment)", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el /* not a comment */", { x: 100, duration: 1 }, 0);
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(1);
expect(result.animations[0].targetSelector).toBe("#el /* not a comment */");
});
});
// ── 10. Arrow Functions as Values ──────────────────────────────────────────
describe("10. Arrow functions as values", () => {
it("arrow function property becomes __raw", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el", { x: (i) => i * 50, opacity: 1, duration: 1 }, 0);
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(1);
// Arrow function is not resolvable
const xVal = result.animations[0].properties.x;
expect(typeof xVal === "string" && xVal.startsWith("__raw:")).toBe(true);
// Resolvable values still work
expect(result.animations[0].properties.opacity).toBe(1);
});
it("arrow function in stagger becomes __raw extra", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to(".items", { opacity: 1, duration: 0.5, stagger: (i) => i * 0.1 }, 0);
`;
const result = parseGsapScript(script);
expect(result.animations[0].extras).toBeDefined();
const stagger = String(result.animations[0].extras!.stagger);
expect(stagger.startsWith("__raw:")).toBe(true);
});
it("arrow function round-trips via serialization", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el", { x: (i) => i * 50, opacity: 1, duration: 1 }, 0);
`;
const parsed = parseGsapScript(script);
const serialized = serializeGsapAnimations(parsed.animations, parsed.timelineVar, {
preamble: parsed.preamble,
postamble: parsed.postamble,
});
// The raw arrow function should be emitted without quotes
expect(serialized).toContain("(i) => i * 50");
expect(serialized).not.toContain('"(i) => i * 50"');
});
});
// ── 11. Spread Operator ────────────────────────────────────────────────────
describe("11. Spread operator", () => {
it("spread in vars object does not crash — spread properties are skipped", () => {
const script = `
const baseVars = { opacity: 0, x: -50 };
const tl = gsap.timeline({ paused: true });
tl.to("#el", { ...baseVars, y: 100, duration: 1 }, 0);
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(1);
// Spread properties are SpreadElement, not ObjectProperty — they're skipped
// Only explicitly written properties are captured
expect(result.animations[0].properties.y).toBe(100);
expect(result.animations[0].duration).toBe(1);
});
});
// ── 12. Conditional Expressions ────────────────────────────────────────────
describe("12. Conditional expressions", () => {
it("ternary expression becomes __raw", () => {
const script = `
const condition = true;
const tl = gsap.timeline({ paused: true });
tl.to("#el", { x: condition ? 100 : 200, y: 50, duration: 1 }, 0);
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(1);
// ConditionalExpression is not handled by resolveNode
const xVal = result.animations[0].properties.x;
expect(typeof xVal === "string" && xVal.startsWith("__raw:")).toBe(true);
expect(result.animations[0].properties.y).toBe(50);
});
it("conditional in position argument defaults to 0", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el", { x: 100, duration: 1 }, someCondition ? 0 : 2);
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(1);
// Position can't be resolved — falls back to 0
expect(result.animations[0].position).toBe(0);
});
});
// ── 13. Round-Trip Stability ───────────────────────────────────────────────
describe("13. Round-trip stability", () => {
it("basic .to() round-trips", () => {
assertRoundTrip(`
const tl = gsap.timeline({ paused: true });
tl.to("#el", { opacity: 1, x: 50, duration: 0.5, ease: "power2.out" }, 0);
`);
});
it("basic .from() round-trips", () => {
assertRoundTrip(`
const tl = gsap.timeline({ paused: true });
tl.from("#el", { opacity: 0, y: -100, duration: 1, ease: "back.out" }, 0.5);
`);
});
it("basic .set() round-trips", () => {
assertRoundTrip(`
const tl = gsap.timeline({ paused: true });
tl.set("#el", { opacity: 0, scale: 0.5 }, 0);
`);
});
it("basic .fromTo() round-trips", () => {
assertRoundTrip(`
const tl = gsap.timeline({ paused: true });
tl.fromTo("#el", { opacity: 0 }, { opacity: 1, duration: 1, ease: "power1.inOut" }, 2);
`);
});
it("stagger extra round-trips", () => {
assertRoundTrip(`
const tl = gsap.timeline({ paused: true });
tl.to(".items", { opacity: 1, duration: 0.5, stagger: 0.1 }, 0);
`);
});
it("yoyo + repeat extras round-trip", () => {
assertRoundTrip(`
const tl = gsap.timeline({ paused: true });
tl.to("#el", { x: 100, duration: 1, yoyo: true, repeat: 3, repeatDelay: 0.2 }, 0);
`);
});
it("multiple tweens round-trip with ordering preserved", () => {
assertRoundTrip(`
const tl = gsap.timeline({ paused: true });
tl.set("#el1", { opacity: 0 }, 0);
tl.to("#el1", { opacity: 1, duration: 0.5 }, 0);
tl.to("#el2", { x: 100, duration: 1 }, 1);
tl.from("#el3", { y: -50, duration: 0.3 }, 2);
`);
});
it("string position round-trips", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el1", { opacity: 1, duration: 0.5 }, "+=1");
tl.to("#el2", { x: 100, duration: 1 }, "<");
`;
const parsed1 = parseGsapScript(script);
const serialized = serializeGsapAnimations(parsed1.animations, parsed1.timelineVar, {
preamble: parsed1.preamble,
postamble: parsed1.postamble,
});
const parsed2 = parseGsapScript(serialized);
expect(parsed2.animations[0].position).toBe("+=1");
expect(parsed2.animations[1].position).toBe("<");
});
it("double round-trip: parse -> serialize -> parse -> serialize -> parse gives stable IR", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.set("#a", { opacity: 0 }, 0);
tl.to("#a", { opacity: 1, x: 100, duration: 0.5, ease: "power2.out" }, 0.5);
tl.to("#b", { y: -50, scale: 1.5, duration: 1, stagger: 0.1 }, 1);
`;
const parsed1 = parseGsapScript(script);
const ser1 = serializeGsapAnimations(parsed1.animations, parsed1.timelineVar, {
preamble: parsed1.preamble,
postamble: parsed1.postamble,
});
const parsed2 = parseGsapScript(ser1);
const ser2 = serializeGsapAnimations(parsed2.animations, parsed2.timelineVar, {
preamble: parsed2.preamble,
postamble: parsed2.postamble,
});
const parsed3 = parseGsapScript(ser2);
// Third parse should match second parse exactly
expect(parsed3.animations.length).toBe(parsed2.animations.length);
for (let i = 0; i < parsed2.animations.length; i++) {
expect(parsed3.animations[i].targetSelector).toBe(parsed2.animations[i].targetSelector);
expect(parsed3.animations[i].method).toBe(parsed2.animations[i].method);
expect(parsed3.animations[i].position).toEqual(parsed2.animations[i].position);
expect(parsed3.animations[i].properties).toEqual(parsed2.animations[i].properties);
}
});
});
// ── 14. ID Collision ───────────────────────────────────────────────────────
describe("14. ID collision", () => {
it("three tweens with same selector, method, position get disambiguated", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el", { opacity: 0, duration: 0.3 }, 0);
tl.to("#el", { x: 100, duration: 0.5 }, 0);
tl.to("#el", { y: 50, duration: 0.7 }, 0);
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(3);
const ids = result.animations.map((a) => a.id);
// All IDs must be unique
expect(new Set(ids).size).toBe(3);
expect(ids[0]).toBe("#el-to-0");
expect(ids[1]).toBe("#el-to-0-2");
expect(ids[2]).toBe("#el-to-0-3");
});
it("disambiguated IDs are stable across parses", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el", { opacity: 0 }, 0);
tl.to("#el", { x: 100 }, 0);
`;
const r1 = parseGsapScript(script);
const r2 = parseGsapScript(script);
expect(r1.animations[0].id).toBe(r2.animations[0].id);
expect(r1.animations[1].id).toBe(r2.animations[1].id);
});
it("mutation by ID targets the correct animation among collisions", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el", { opacity: 0, duration: 0.3 }, 0);
tl.to("#el", { opacity: 1, duration: 0.5 }, 0);
`;
const parsed = parseGsapScript(script);
const secondId = parsed.animations[1].id; // "#el-to-0-2"
const updated = updateAnimationInScript(script, secondId, { duration: 2 });
const reparsed = parseGsapScript(updated);
// The second animation should have updated duration
expect(reparsed.animations[1].duration).toBe(2);
// The first should be untouched
expect(reparsed.animations[0].duration).toBe(0.3);
});
});
// ── 15. Very Long Scripts ──────────────────────────────────────────────────
describe("15. Very long scripts (50+ tweens)", () => {
it("parses 50 sequential tweens", () => {
const tweens = Array.from(
{ length: 50 },
(_, i) =>
`tl.to("#el${i}", { x: ${i * 10}, opacity: ${(i % 10) / 10}, duration: 0.5 }, ${i * 0.5});`,
).join("\n ");
const script = `
const tl = gsap.timeline({ paused: true });
${tweens}
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(50);
// Spot check first and last
expect(result.animations[0].targetSelector).toBe("#el0");
expect(result.animations[0].properties.x).toBe(0);
expect(result.animations[49].targetSelector).toBe("#el49");
expect(result.animations[49].properties.x).toBe(490);
});
it("parses 100 tweens targeting the same element", () => {
const tweens = Array.from(
{ length: 100 },
(_, i) => `tl.to("#el", { x: ${i}, duration: 0.1 }, ${i * 0.1});`,
).join("\n ");
const script = `
const tl = gsap.timeline({ paused: true });
${tweens}
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(100);
// All IDs must be unique despite same selector
const ids = result.animations.map((a) => a.id);
expect(new Set(ids).size).toBe(100);
});
it("round-trips 50 tweens", () => {
const tweens = Array.from(
{ length: 50 },
(_, i) => `tl.to("#el${i}", { x: ${i * 10}, duration: 0.5 }, ${i * 0.5});`,
).join("\n ");
const script = `
const tl = gsap.timeline({ paused: true });
${tweens}
`;
const parsed = parseGsapScript(script);
const serialized = serializeGsapAnimations(parsed.animations, parsed.timelineVar, {
preamble: parsed.preamble,
postamble: parsed.postamble,
});
const reparsed = parseGsapScript(serialized);
expect(reparsed.animations.length).toBe(50);
for (let i = 0; i < 50; i++) {
expect(reparsed.animations[i].targetSelector).toBe(parsed.animations[i].targetSelector);
expect(reparsed.animations[i].properties.x).toBe(parsed.animations[i].properties.x);
}
});
});
// ── Additional Edge Cases ──────────────────────────────────────────────────
describe("Additional edge cases", () => {
it("selector with special CSS characters", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#my-element_v2.class", { x: 100, duration: 1 }, 0);
tl.to(".parent > .child", { y: 50, duration: 0.5 }, 0);
tl.to("[data-anim='fade']", { opacity: 1, duration: 1 }, 0);
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(3);
expect(result.animations[0].targetSelector).toBe("#my-element_v2.class");
expect(result.animations[1].targetSelector).toBe(".parent > .child");
expect(result.animations[2].targetSelector).toBe("[data-anim='fade']");
});
it("string concatenation in property value", () => {
const script = `
const prefix = "100";
const tl = gsap.timeline({ paused: true });
tl.to("#el", { x: prefix + "px", y: 50, duration: 1 }, 0);
`;
const result = parseGsapScript(script);
expect(result.animations[0].properties.x).toBe("100px");
expect(result.animations[0].properties.y).toBe(50);
});
it("arithmetic in position argument", () => {
const script = `
const START = 2;
const tl = gsap.timeline({ paused: true });
tl.to("#el", { x: 100, duration: 1 }, START + 0.5);
`;
const result = parseGsapScript(script);
expect(result.animations[0].position).toBe(2.5);
});
it("var declaration for timeline", () => {
const script = `
var tl = gsap.timeline({ paused: true });
tl.to("#el", { opacity: 1, duration: 1 }, 0);
`;
const result = parseGsapScript(script);
expect(result.timelineVar).toBe("tl");
expect(result.animations).toHaveLength(1);
});
it("assignment expression for timeline (no declaration keyword)", () => {
const script = `
window.tl = gsap.timeline({ paused: true });
`;
const result = parseGsapScript(script);
// Window member expression is not a bare Identifier, so timelineVar may not be found
// The parser checks for Identifier left in assignment expressions
// window.tl is a MemberExpression, not Identifier — should not set timelineVar
expectSafeDefault(result);
});
it("non-GSAP method calls on the timeline are ignored", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el", { opacity: 1, duration: 0.5 }, 0);
tl.play();
tl.pause();
tl.reverse();
tl.seek(2);
`;
const result = parseGsapScript(script);
// Only .to() is a tween method — play/pause/reverse/seek are not in GSAP_METHODS
expect(result.animations).toHaveLength(1);
});
it("tween with only one argument (selector only) is skipped", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el");
tl.to("#el2", { opacity: 1, duration: 0.5 }, 0);
`;
const result = parseGsapScript(script);
// First tween has < 2 args — should be skipped
expect(result.animations).toHaveLength(1);
expect(result.animations[0].targetSelector).toBe("#el2");
});
it("non-string selector (variable reference) is skipped", () => {
const script = `
const el = document.querySelector("#el");
const tl = gsap.timeline({ paused: true });
tl.to(el, { opacity: 1, duration: 0.5 }, 0);
tl.to("#el2", { x: 100, duration: 0.5 }, 0);
`;
const result = parseGsapScript(script);
// First tween has a variable reference as selector, not a string literal — skipped
expect(result.animations).toHaveLength(1);
expect(result.animations[0].targetSelector).toBe("#el2");
});
it("boolean values in vars are not included in properties", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el", { opacity: 1, immediateRender: false, duration: 0.5 }, 0);
`;
const result = parseGsapScript(script);
expect(result.animations[0].properties.opacity).toBe(1);
// immediateRender is in EXTRAS_KEYS, should be in extras
expect(result.animations[0].extras).toBeDefined();
expect(result.animations[0].extras!.immediateRender).toBeDefined();
// Should not be in properties
expect(result.animations[0].properties).not.toHaveProperty("immediateRender");
});
it("callbacks (onComplete etc.) are dropped", () => {
// Note: validation would flag these, but the parser just drops them
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el", { opacity: 1, duration: 1, onComplete: function() { console.log("done"); } }, 0);
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(1);
expect(result.animations[0].properties).not.toHaveProperty("onComplete");
expect(result.animations[0].extras).toBeUndefined();
});
it("delay is not included in properties (BUILTIN_VAR_KEYS)", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el", { opacity: 1, duration: 0.5, delay: 0.2 }, 0);
`;
const result = parseGsapScript(script);
expect(result.animations[0].properties).not.toHaveProperty("delay");
expect(result.animations[0].properties).not.toHaveProperty("duration");
});
it("percentage string values in properties survive", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el", { width: "50%", opacity: 1, duration: 1 }, 0);
`;
const result = parseGsapScript(script);
expect(result.animations[0].properties.width).toBe("50%");
expect(result.animations[0].properties.opacity).toBe(1);
});
it("scope resolution: binary expression with one unresolvable side", () => {
const script = `
const BASE = 100;
const tl = gsap.timeline({ paused: true });
tl.to("#el", { x: BASE + unknownVar, y: BASE * 2, duration: 1 }, 0);
`;
const result = parseGsapScript(script);
// BASE + unknownVar: left is 100, right is undefined => result is undefined => __raw
const xVal = result.animations[0].properties.x;
expect(typeof xVal === "string" && xVal.startsWith("__raw:")).toBe(true);
// BASE * 2: both resolved => 200
expect(result.animations[0].properties.y).toBe(200);
});
it("negative position in ID generation", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el", { x: 100, duration: 1 }, -2.5);
`;
const result = parseGsapScript(script);
// ID uses Math.round(position * 1000) for numeric positions
expect(result.animations[0].id).toBe("#el-to--2500");
});
it("fromTo with no position arg defaults to 0", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.fromTo("#el", { opacity: 0 }, { opacity: 1, duration: 1 });
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(1);
// For fromTo, position is args[3] which is undefined => defaults to 0
expect(result.animations[0].position).toBe(0);
});
});
+276 -10
View File
@@ -8,6 +8,9 @@ import {
validateCompositionGsap,
getAnimationsForElement,
keyframesToGsapAnimations,
addAnimationToScript,
removeAnimationFromScript,
updateAnimationInScript,
} from "./gsapParser.js";
import type { GsapAnimation } from "./gsapParser.js";
import type { Keyframe } from "../core.types";
@@ -79,9 +82,7 @@ describe("parseGsapScript", () => {
expect(anim.position).toBe(2);
});
it("parseObjectLiteral does not match negative numbers (known limitation)", () => {
// The regex in parseObjectLiteral only matches [\d.]+, not negative numbers.
// Negative values like x: -100 won't be parsed by the object literal parser.
it("parses negative numbers in property values", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.fromTo("#el5", { opacity: 0, x: -100 }, { opacity: 1, x: 0, duration: 1 }, 0);
@@ -92,8 +93,7 @@ describe("parseGsapScript", () => {
const anim = result.animations[0];
expect(anim.fromProperties).toBeDefined();
expect(anim.fromProperties?.opacity).toBe(0);
// -100 is not parseable by the regex, so x won't be in fromProperties
expect(anim.fromProperties?.x).toBeUndefined();
expect(anim.fromProperties?.x).toBe(-100);
});
it("handles an empty script", () => {
@@ -142,7 +142,7 @@ describe("parseGsapScript", () => {
expect(result.animations[2].method).toBe("to");
});
it("filters out unsupported properties from animations", () => {
it("extracts all GSAP properties including non-standard ones", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el1", { opacity: 1, backgroundColor: "red", x: 50, duration: 0.5 }, 0);
@@ -151,8 +151,7 @@ describe("parseGsapScript", () => {
expect(result.animations[0].properties.opacity).toBe(1);
expect(result.animations[0].properties.x).toBe(50);
// backgroundColor is not in SUPPORTED_PROPS, so it's filtered out
expect(result.animations[0].properties.backgroundColor).toBeUndefined();
expect(result.animations[0].properties.backgroundColor).toBe("red");
});
it("extracts ease from properties", () => {
@@ -175,6 +174,197 @@ describe("parseGsapScript", () => {
expect(result.timelineVar).toBe("timeline");
expect(result.animations).toHaveLength(1);
});
it("preserves string position values like '+=1' and '<'", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el1", { opacity: 1, duration: 0.5 }, "+=1");
tl.to("#el2", { x: 100, duration: 1 }, "<");
tl.to("#el3", { y: 50, duration: 0.3 }, "-=0.5");
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(3);
expect(result.animations[0].position).toBe("+=1");
expect(result.animations[1].position).toBe("<");
expect(result.animations[2].position).toBe("-=0.5");
});
it("resolves variable references from const declarations in the same script", () => {
const script = `
const FADE = 0.8;
const OFFSET = -60;
const MY_EASE = "power3.out";
const tl = gsap.timeline({ paused: true });
tl.from("#el1", { y: OFFSET, opacity: 0, duration: FADE, ease: MY_EASE }, 0);
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(1);
expect(result.animations[0].properties.y).toBe(-60);
expect(result.animations[0].properties.opacity).toBe(0);
expect(result.animations[0].duration).toBe(0.8);
expect(result.animations[0].ease).toBe("power3.out");
});
it("resolves computed expressions from scope bindings", () => {
const script = `
const BASE = 100;
const HALF = BASE / 2;
const tl = gsap.timeline({ paused: true });
tl.to("#el1", { x: HALF, duration: 1 }, 0);
`;
const result = parseGsapScript(script);
expect(result.animations[0].properties.x).toBe(50);
});
it("preserves unresolvable references as __raw: prefixed strings", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el1", { opacity: someUndefinedVar, x: 50, duration: 1 }, 0);
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(1);
expect(result.animations[0].properties.x).toBe(50);
expect(result.animations[0].properties.opacity).toBe("__raw:someUndefinedVar");
});
it("generates stable content-based IDs", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el1", { opacity: 1, duration: 0.5 }, 0);
tl.to("#el2", { x: 100, duration: 1 }, 1);
`;
const result1 = parseGsapScript(script);
const result2 = parseGsapScript(script);
// IDs are deterministic across parses
expect(result1.animations[0].id).toBe(result2.animations[0].id);
expect(result1.animations[1].id).toBe(result2.animations[1].id);
// IDs encode selector, method, and position
expect(result1.animations[0].id).toBe("#el1-to-0");
expect(result1.animations[1].id).toBe("#el2-to-1000");
});
it("disambiguates colliding IDs with a suffix", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el1", { opacity: 0, duration: 0.3 }, 0);
tl.to("#el1", { opacity: 1, duration: 0.5 }, 0);
`;
const result = parseGsapScript(script);
expect(result.animations[0].id).toBe("#el1-to-0");
expect(result.animations[1].id).toBe("#el1-to-0-2");
});
it("uses string position in ID for relative positions", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el1", { opacity: 1, duration: 0.5 }, "+=1");
`;
const result = parseGsapScript(script);
expect(result.animations[0].id).toBe("#el1-to-+=1");
});
});
describe("stagger/yoyo/repeat round-trip", () => {
it("preserves stagger as extras on parse", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to(".items", { opacity: 1, duration: 0.5, stagger: 0.1 }, 0);
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(1);
expect(result.animations[0].extras).toBeDefined();
expect(result.animations[0].extras!.stagger).toBe("__raw:0.1");
expect(result.animations[0].properties.opacity).toBe(1);
// stagger should NOT appear in properties
expect(result.animations[0].properties).not.toHaveProperty("stagger");
});
it("preserves complex stagger object on round-trip", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to(".items", { opacity: 1, duration: 0.5, stagger: { each: 0.15, from: "start" } }, 0);
`;
const parsed = parseGsapScript(script);
const serialized = serializeGsapAnimations(parsed.animations, parsed.timelineVar, {
preamble: parsed.preamble,
postamble: parsed.postamble,
});
expect(serialized).toContain("stagger: {");
expect(serialized).toContain("each: 0.15");
expect(serialized).toContain('from: "start"');
});
it("preserves yoyo and repeat on round-trip", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el1", { x: 100, duration: 1, yoyo: true, repeat: 3, repeatDelay: 0.2 }, 0);
`;
const parsed = parseGsapScript(script);
const serialized = serializeGsapAnimations(parsed.animations, parsed.timelineVar, {
preamble: parsed.preamble,
postamble: parsed.postamble,
});
expect(serialized).toContain("yoyo: true");
expect(serialized).toContain("repeat: 3");
expect(serialized).toContain("repeatDelay: 0.2");
});
it("survives a full parse-edit-serialize round-trip with stagger intact", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to(".items", { opacity: 1, x: 50, duration: 0.5, stagger: 0.1, ease: "power2.out" }, 0);
`;
const parsed = parseGsapScript(script);
const animId = parsed.animations[0].id;
// Simulate an edit — change opacity to 0.5
const updatedScript = updateAnimationInScript(script, animId, {
properties: { opacity: 0.5, x: 50 },
});
// stagger should still be in the output
expect(updatedScript).toContain("stagger: 0.1");
expect(updatedScript).toContain("opacity: 0.5");
});
});
describe("unresolvable value round-trip", () => {
it("preserves unresolvable property values through serialize", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el1", { opacity: someFn(), x: 50, duration: 1 }, 0);
`;
const parsed = parseGsapScript(script);
const serialized = serializeGsapAnimations(parsed.animations, parsed.timelineVar, {
preamble: parsed.preamble,
postamble: parsed.postamble,
});
// The raw expression should survive — emitted without quotes
expect(serialized).toContain("opacity: someFn()");
expect(serialized).toContain("x: 50");
});
it("preserves complex unresolvable expressions", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el1", { x: getOffset() + 10, y: 200, duration: 1 }, 0);
`;
const parsed = parseGsapScript(script);
// x is unresolvable (function call in expression), y is resolvable
expect(parsed.animations[0].properties.y).toBe(200);
expect(String(parsed.animations[0].properties.x)).toMatch(/^__raw:/);
});
});
describe("gsapAnimationsToKeyframes", () => {
@@ -244,7 +434,7 @@ describe("gsapAnimationsToKeyframes", () => {
targetSelector: "#el1",
method: "set",
position: 5,
properties: { x: 0, y: 0, scale: 1 },
properties: { x: 0, y: 0 },
},
{
id: "anim-2",
@@ -258,7 +448,6 @@ describe("gsapAnimationsToKeyframes", () => {
const keyframes = gsapAnimationsToKeyframes(animations, 5, { skipBaseSet: true });
// The set at position 5 (time=0) with x=0, y=0, scale=1 (base values) should be skipped
expect(keyframes).toHaveLength(1);
expect(keyframes[0].id).toBe("anim-2");
});
@@ -527,6 +716,83 @@ describe("getAnimationsForElement", () => {
});
});
describe("mutation functions parse-fail safety", () => {
const garbage = "this is not valid javascript @@@ {{{{";
it("updateAnimationInScript returns original script on parse failure", () => {
const result = updateAnimationInScript(garbage, "anim-1", { duration: 2 });
expect(result).toBe(garbage);
});
it("addAnimationToScript returns original script on parse failure", () => {
const result = addAnimationToScript(garbage, {
targetSelector: "#el1",
method: "to",
position: 0,
properties: { opacity: 1 },
duration: 1,
});
expect(result.script).toBe(garbage);
expect(result.id).toBe("");
});
it("removeAnimationFromScript returns original script on parse failure", () => {
const result = removeAnimationFromScript(garbage, "anim-1");
expect(result).toBe(garbage);
});
});
describe("serializeGsapAnimations quote escaping", () => {
it("escapes quotes and backslashes in string property values", () => {
const animations: GsapAnimation[] = [
{
id: "anim-1",
targetSelector: "#el1",
method: "to",
position: 0,
properties: { content: 'say "hello"' },
duration: 1,
},
];
const result = serializeGsapAnimations(animations);
// JSON.stringify produces escaped quotes
expect(result).toContain('content: "say \\"hello\\""');
});
it("escapes backslashes in string property values", () => {
const animations: GsapAnimation[] = [
{
id: "anim-1",
targetSelector: "#el1",
method: "to",
position: 0,
properties: { path: "C:\\Users\\test" },
duration: 1,
},
];
const result = serializeGsapAnimations(animations);
expect(result).toContain('path: "C:\\\\Users\\\\test"');
});
it("serializes string position values correctly", () => {
const animations: GsapAnimation[] = [
{
id: "anim-1",
targetSelector: "#el1",
method: "to",
position: "+=1",
properties: { opacity: 1 },
duration: 0.5,
},
];
const result = serializeGsapAnimations(animations);
expect(result).toContain('"+=1"');
});
});
describe("SUPPORTED_PROPS", () => {
it("includes expected properties", () => {
expect(SUPPORTED_PROPS).toContain("opacity");
+367 -450
View File
@@ -1,299 +1,395 @@
import type { Keyframe, KeyframeProperties, ValidationResult } from "../core.types";
/**
* Node-only GSAP AST parser. Depends on recast / @babel/parser, which compile
* to CommonJS that calls `require("fs")` — so this module must never be in the
* static import graph of isomorphic/browser code. It is reachable only via the
* `@hyperframes/core/gsap-parser` subpath (studio-api mutations + the linter).
*
* Recast-free helpers (serialization, keyframe conversion, validation, types)
* live in `./gsapSerialize` and are re-exported here so this subpath exposes the
* full surface for tests and server-side consumers.
*/
import * as recast from "recast";
import { parse as babelParse } from "@babel/parser";
import {
type GsapAnimation,
type GsapMethod,
type ParsedGsap,
serializeGsapAnimations,
} from "./gsapSerialize";
export type GsapMethod = "set" | "to" | "from" | "fromTo";
export interface GsapAnimation {
id: string;
targetSelector: string;
method: GsapMethod;
position: number;
properties: Record<string, number | string>;
fromProperties?: Record<string, number | string>;
duration?: number;
ease?: string;
}
export interface ParsedGsap {
animations: GsapAnimation[];
timelineVar: string;
preamble: string;
postamble: string;
}
export type { GsapAnimation, GsapMethod, ParsedGsap } from "./gsapSerialize";
export {
serializeGsapAnimations,
getAnimationsForElement,
validateCompositionGsap,
keyframesToGsapAnimations,
gsapAnimationsToKeyframes,
SUPPORTED_PROPS,
SUPPORTED_EASES,
} from "./gsapSerialize";
const GSAP_METHODS = new Set<string>(["set", "to", "from", "fromTo"]);
export const SUPPORTED_PROPS = [
"opacity",
"visibility",
"x",
"y",
"scale",
"scaleX",
"scaleY",
"rotation",
"autoAlpha",
"width",
"height",
];
// ── Recast AST Helpers ──────────────────────────────────────────────────────
export const SUPPORTED_EASES = [
"none",
"power1.in",
"power1.out",
"power1.inOut",
"power2.in",
"power2.out",
"power2.inOut",
"power3.in",
"power3.out",
"power3.inOut",
"power4.in",
"power4.out",
"power4.inOut",
"back.in",
"back.out",
"back.inOut",
"elastic.in",
"elastic.out",
"elastic.inOut",
"bounce.in",
"bounce.out",
"bounce.inOut",
"expo.in",
"expo.out",
"expo.inOut",
];
type ScopeBindings = ReadonlyMap<string, number | string | boolean>;
function parseObjectLiteral(str: string): Record<string, number | string> {
const result: Record<string, number | string> = {};
function parseScript(script: string) {
return recast.parse(script, {
parser: {
parse(source: string) {
return babelParse(source, { sourceType: "script", plugins: [], tokens: true });
},
},
});
}
const cleaned = str.replace(/^\{|\}$/g, "").trim();
if (!cleaned) return result;
function collectScopeBindings(ast: any): ScopeBindings {
const bindings = new Map<string, number | string | boolean>();
recast.types.visit(ast, {
visitVariableDeclarator(path: any) {
const name = path.node.id?.name;
const init = path.node.init;
if (name && init) {
const val = resolveNode(init, bindings);
if (val !== undefined) bindings.set(name, val);
}
this.traverse(path);
},
});
return bindings;
}
const propRegex = /(\w+)\s*:\s*("[^"]*"|'[^']*'|[\d.]+|[a-zA-Z_][\w.]*)/g;
let match;
while ((match = propRegex.exec(cleaned)) !== null) {
const key = match[1] ?? "";
let value: string | number = match[2] ?? "";
if (typeof value === "string") {
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
} else if (!isNaN(Number(value))) {
value = Number(value);
function resolveNode(
node: any,
scope: ReadonlyMap<string, number | string | boolean>,
): number | string | boolean | undefined {
if (!node) return undefined;
if (node.type === "NumericLiteral" || (node.type === "Literal" && typeof node.value === "number"))
return node.value;
if (node.type === "StringLiteral" || (node.type === "Literal" && typeof node.value === "string"))
return node.value;
if (
node.type === "BooleanLiteral" ||
(node.type === "Literal" && typeof node.value === "boolean")
)
return node.value;
if (node.type === "UnaryExpression" && node.operator === "-" && node.argument) {
const val = resolveNode(node.argument, scope);
return typeof val === "number" ? -val : undefined;
}
if (node.type === "BinaryExpression") {
const left = resolveNode(node.left, scope);
const right = resolveNode(node.right, scope);
if (typeof left === "number" && typeof right === "number") {
switch (node.operator) {
case "+":
return left + right;
case "-":
return left - right;
case "*":
return left * right;
case "/":
return right !== 0 ? left / right : undefined;
}
}
result[key] = value;
if (typeof left === "string" && node.operator === "+") return left + String(right ?? "");
if (typeof right === "string" && node.operator === "+") return String(left ?? "") + right;
}
if (node.type === "Identifier" && scope.has(node.name)) {
return scope.get(node.name);
}
if (node.type === "TemplateLiteral" && node.expressions?.length === 0) {
return node.quasis?.[0]?.value?.cooked ?? undefined;
}
return undefined;
}
function extractLiteralValue(node: any, scope: ScopeBindings): unknown {
return resolveNode(node, scope);
}
function objectExpressionToRecord(node: any, scope: ScopeBindings): Record<string, unknown> {
const result: Record<string, unknown> = {};
if (node?.type !== "ObjectExpression") return result;
for (const prop of node.properties ?? []) {
if (prop.type !== "ObjectProperty" && prop.type !== "Property") continue;
const key = prop.key?.name ?? prop.key?.value;
if (!key) continue;
const resolved = resolveNode(prop.value, scope);
if (resolved !== undefined) {
result[key] = resolved;
} else {
// Preserve unresolvable values as raw source text so they survive round-trips
result[key] = `__raw:${recast.print(prop.value).code}`;
}
}
return result;
}
function findMatchingBrace(str: string, startIndex: number): number {
let depth = 0;
for (let i = startIndex; i < str.length; i++) {
if (str[i] === "{") depth++;
else if (str[i] === "}") {
depth--;
if (depth === 0) return i;
}
}
return -1;
// ── Timeline Variable Detection ─────────────────────────────────────────────
function isGsapTimelineCall(node: any): boolean {
return (
node?.type === "CallExpression" &&
node.callee?.type === "MemberExpression" &&
node.callee.object?.name === "gsap" &&
node.callee.property?.name === "timeline"
);
}
export function parseGsapScript(script: string): ParsedGsap {
const animations: GsapAnimation[] = [];
let idCounter = 0;
const timelineMatch = script.match(/(?:const|let|var)\s+(\w+)\s*=\s*gsap\.timeline/);
const timelineVar = timelineMatch ? (timelineMatch[1] ?? "tl") : "tl";
const preambleMatch = script.match(
new RegExp(
`^[\\s\\S]*?(?:const|let|var)\\s+${timelineVar}\\s*=\\s*gsap\\.timeline\\s*\\([^)]*\\)\\s*;?`,
),
);
const preamble = preambleMatch
? preambleMatch[0]
: `const ${timelineVar} = gsap.timeline({ paused: true });`;
const methodPattern = new RegExp(
`${timelineVar}\\.(set|to|from|fromTo)\\s*\\(([^)]+(?:\\{[^}]*\\}[^)]*)+)\\)`,
"g",
);
let match;
while ((match = methodPattern.exec(script)) !== null) {
const rawMethod = match[1];
if (!rawMethod || !GSAP_METHODS.has(rawMethod)) continue;
const method: GsapMethod = rawMethod as GsapMethod;
const argsStr = match[2] ?? "";
const animation = parseGsapCall(method, argsStr, ++idCounter);
if (animation) {
animations.push(animation);
}
}
const lastAnimIdx = script.lastIndexOf(`${timelineVar}.`);
let postamble = "";
if (lastAnimIdx !== -1) {
const afterLastAnim = script.slice(lastAnimIdx);
const endOfCall = afterLastAnim.indexOf(";");
if (endOfCall !== -1) {
postamble = script.slice(lastAnimIdx + endOfCall + 1).trim();
}
}
return { animations, timelineVar, preamble, postamble };
interface TimelineDetection {
timelineVar: string | null;
timelineCount: number;
}
function parseGsapCall(method: GsapMethod, argsStr: string, idNum: number): GsapAnimation | null {
const selectorMatch = argsStr.match(/^\s*["']([^"']+)["']\s*,/);
if (!selectorMatch) return null;
function findTimelineVar(ast: any): TimelineDetection {
let timelineVar: string | null = null;
let timelineCount = 0;
recast.types.visit(ast, {
visitVariableDeclarator(path: any) {
if (isGsapTimelineCall(path.node.init)) {
timelineCount += 1;
if (!timelineVar) timelineVar = path.node.id?.name ?? null;
}
this.traverse(path);
},
visitAssignmentExpression(path: any) {
if (isGsapTimelineCall(path.node.right)) {
timelineCount += 1;
if (!timelineVar) {
const left = path.node.left;
if (left?.type === "Identifier") timelineVar = left.name;
}
}
this.traverse(path);
},
});
return { timelineVar, timelineCount };
}
const targetSelector = selectorMatch[1] ?? "";
const afterSelector = argsStr.slice(selectorMatch[0].length);
// ── Find All Tween Calls ────────────────────────────────────────────────────
interface TweenCallInfo {
path: any;
node: any;
method: GsapMethod;
selector: string;
varsArg: any;
fromArg?: any;
positionArg?: any;
}
function findAllTweenCalls(ast: any, timelineVar: string): TweenCallInfo[] {
const results: TweenCallInfo[] = [];
recast.types.visit(ast, {
visitCallExpression(path: any) {
const node = path.node;
const callee = node.callee;
if (
callee?.type === "MemberExpression" &&
callee.object?.type === "Identifier" &&
callee.object.name === timelineVar &&
callee.property?.type === "Identifier"
) {
const method = callee.property.name;
if (!GSAP_METHODS.has(method)) {
this.traverse(path);
return;
}
const args = node.arguments;
if (args.length < 2) {
this.traverse(path);
return;
}
const selectorArg = args[0];
const selectorValue =
selectorArg.type === "StringLiteral" || selectorArg.type === "Literal"
? String(selectorArg.value)
: null;
if (!selectorValue) {
this.traverse(path);
return;
}
if (method === "fromTo") {
results.push({
path,
node,
method: "fromTo",
selector: selectorValue,
fromArg: args[1],
varsArg: args[2],
positionArg: args[3],
});
} else {
results.push({
path,
node,
method: method as GsapMethod,
selector: selectorValue,
varsArg: args[1],
positionArg: args[2],
});
}
}
this.traverse(path);
},
});
return results;
}
/** Keys that are stored on dedicated GsapAnimation fields (not in properties/extras). */
const BUILTIN_VAR_KEYS = new Set(["duration", "ease", "delay"]);
/** Keys that are never preserved (callbacks / advanced patterns). */
const DROPPED_VAR_KEYS = new Set(["keyframes", "onComplete", "onStart", "onUpdate", "onRepeat"]);
/** Keys that belong in `extras` — non-editable GSAP config that must survive round-trips. */
const EXTRAS_KEYS = new Set([
"stagger",
"yoyo",
"repeat",
"repeatDelay",
"snap",
"overwrite",
"immediateRender",
]);
/**
* Extract raw source text for a property in an ObjectExpression AST node.
* Returns the printed source of the value node, suitable for verbatim re-emission.
*/
function extractRawPropertySource(varsArgNode: any, key: string): string | undefined {
if (varsArgNode?.type !== "ObjectExpression") return undefined;
for (const prop of varsArgNode.properties ?? []) {
if (prop.type !== "ObjectProperty" && prop.type !== "Property") continue;
const propKey = prop.key?.name ?? prop.key?.value;
if (propKey === key) {
return recast.print(prop.value).code;
}
}
return undefined;
}
function tweenCallToAnimation(
call: TweenCallInfo,
scope: ScopeBindings,
): Omit<GsapAnimation, "id"> {
const vars = objectExpressionToRecord(call.varsArg, scope);
const properties: Record<string, number | string> = {};
const extras: Record<string, unknown> = {};
for (const [key, val] of Object.entries(vars)) {
if (BUILTIN_VAR_KEYS.has(key)) continue;
if (DROPPED_VAR_KEYS.has(key)) continue;
if (EXTRAS_KEYS.has(key)) {
// For extras, prefer the raw AST source so complex objects like
// `stagger: { each: 0.15, from: "start" }` survive verbatim.
const rawSource = extractRawPropertySource(call.varsArg, key);
if (rawSource !== undefined) {
extras[key] = `__raw:${rawSource}`;
} else if (val !== undefined) {
extras[key] = val;
}
continue;
}
if (typeof val === "number" || typeof val === "string") {
properties[key] = val;
}
}
let properties: Record<string, number | string> = {};
let fromProperties: Record<string, number | string> | undefined;
let position = 0;
if (method === "fromTo") {
const firstBrace = afterSelector.indexOf("{");
const firstEnd = findMatchingBrace(afterSelector, firstBrace);
if (firstBrace === -1 || firstEnd === -1) return null;
fromProperties = parseObjectLiteral(afterSelector.slice(firstBrace, firstEnd + 1));
const secondPart = afterSelector.slice(firstEnd + 1);
const secondBrace = secondPart.indexOf("{");
const secondEnd = findMatchingBrace(secondPart, secondBrace);
if (secondBrace === -1 || secondEnd === -1) return null;
properties = parseObjectLiteral(secondPart.slice(secondBrace, secondEnd + 1));
const afterProps = secondPart.slice(secondEnd + 1);
const posMatch = afterProps.match(/,\s*([\d.]+)/);
if (posMatch) position = parseFloat(posMatch[1] ?? "");
} else {
const braceStart = afterSelector.indexOf("{");
const braceEnd = findMatchingBrace(afterSelector, braceStart);
if (braceStart !== -1 && braceEnd !== -1) {
properties = parseObjectLiteral(afterSelector.slice(braceStart, braceEnd + 1));
const afterProps = afterSelector.slice(braceEnd + 1);
const posMatch = afterProps.match(/,\s*([\d.]+)/);
if (posMatch) position = parseFloat(posMatch[1] ?? "");
}
}
const duration = typeof properties.duration === "number" ? properties.duration : undefined;
const ease = typeof properties.ease === "string" ? properties.ease : undefined;
const filteredProps: Record<string, number | string> = {};
for (const [key, value] of Object.entries(properties)) {
if (SUPPORTED_PROPS.includes(key)) {
filteredProps[key] = value;
}
}
let filteredFromProps: Record<string, number | string> | undefined;
if (fromProperties) {
filteredFromProps = {};
for (const [key, value] of Object.entries(fromProperties)) {
if (SUPPORTED_PROPS.includes(key)) {
filteredFromProps[key] = value;
if (call.method === "fromTo" && call.fromArg) {
fromProperties = {};
const fromVars = objectExpressionToRecord(call.fromArg, scope);
for (const [key, val] of Object.entries(fromVars)) {
if (typeof val === "number" || typeof val === "string") {
fromProperties[key] = val;
}
}
}
return {
id: `anim-${idNum}`,
targetSelector,
method,
const posVal = call.positionArg ? extractLiteralValue(call.positionArg, scope) : 0;
const position: number | string =
typeof posVal === "number" ? posVal : typeof posVal === "string" ? posVal : 0;
const duration = typeof vars.duration === "number" ? vars.duration : undefined;
const ease = typeof vars.ease === "string" ? vars.ease : undefined;
const anim: Omit<GsapAnimation, "id"> = {
targetSelector: call.selector,
method: call.method,
position,
properties: filteredProps,
fromProperties: filteredFromProps,
properties,
fromProperties,
duration,
ease,
};
if (Object.keys(extras).length > 0) anim.extras = extras;
return anim;
}
export function serializeGsapAnimations(
animations: GsapAnimation[],
timelineVar = "tl",
options?: { includeMediaSync?: boolean },
): string {
const sorted = [...animations].sort((a, b) => a.position - b.position);
// ── Stable ID Generation ───────────────────────────────────────────────────
const lines = sorted.map((anim) => {
const selector = `"${anim.targetSelector}"`;
function assignStableIds(anims: Omit<GsapAnimation, "id">[]): GsapAnimation[] {
const counts = new Map<string, number>();
return anims.map((anim) => {
const posKey =
typeof anim.position === "number"
? String(Math.round(anim.position * 1000))
: String(anim.position);
const base = `${anim.targetSelector}-${anim.method}-${posKey}`;
const count = (counts.get(base) ?? 0) + 1;
counts.set(base, count);
const id = count === 1 ? base : `${base}-${count}`;
return { ...anim, id };
});
}
const props: Record<string, number | string> = { ...anim.properties };
if (anim.duration !== undefined) props.duration = anim.duration;
if (anim.ease) props.ease = anim.ease;
// ── Public API ──────────────────────────────────────────────────────────────
const propsStr = serializeObject(props);
export function parseGsapScript(script: string): ParsedGsap {
try {
const ast = parseScript(script);
const scope = collectScopeBindings(ast);
const detection = findTimelineVar(ast);
const timelineVar = detection.timelineVar ?? "tl";
const calls = findAllTweenCalls(ast, timelineVar);
const animations = assignStableIds(calls.map((call) => tweenCallToAnimation(call, scope)));
switch (anim.method) {
case "set":
return ` ${timelineVar}.set(${selector}, ${propsStr}, ${anim.position});`;
case "to":
return ` ${timelineVar}.to(${selector}, ${propsStr}, ${anim.position});`;
case "from":
return ` ${timelineVar}.from(${selector}, ${propsStr}, ${anim.position});`;
case "fromTo": {
const fromStr = serializeObject(anim.fromProperties || {});
return ` ${timelineVar}.fromTo(${selector}, ${fromStr}, ${propsStr}, ${anim.position});`;
const timelineMatch = script.match(
new RegExp(
`^[\\s\\S]*?(?:const|let|var)\\s+${timelineVar}\\s*=\\s*gsap\\.timeline\\s*\\([^)]*\\)\\s*;?`,
),
);
const preamble =
timelineMatch?.[0] ?? `const ${timelineVar} = gsap.timeline({ paused: true });`;
const lastCallIdx = script.lastIndexOf(`${timelineVar}.`);
let postamble = "";
if (lastCallIdx !== -1) {
const afterLast = script.slice(lastCallIdx);
const endOfCall = afterLast.indexOf(";");
if (endOfCall !== -1) {
postamble = script.slice(lastCallIdx + endOfCall + 1).trim();
}
}
});
let mediaSync = "";
if (options?.includeMediaSync) {
mediaSync = `
// Sync media playback
${timelineVar}.eventCallback("onUpdate", function() {
const time = ${timelineVar}.time();
document.querySelectorAll("video[data-start], audio[data-start]").forEach(function(media) {
const start = parseFloat(media.dataset.start);
const end = parseFloat(media.dataset.end) || Infinity;
const mediaTime = time - start;
if (time >= start && time < end) {
if (Math.abs(media.currentTime - mediaTime) > 0.1) {
media.currentTime = mediaTime;
}
if (media.paused && !${timelineVar}.paused()) {
media.play().catch(function() {});
}
} else if (!media.paused) {
media.pause();
}
});
});`;
const result: ParsedGsap = { animations, timelineVar, preamble, postamble };
if (detection.timelineCount > 1) result.multipleTimelines = true;
if (detection.timelineCount > 0 && detection.timelineVar === null)
result.unsupportedTimelinePattern = true;
return result;
} catch {
return { animations: [], timelineVar: "tl", preamble: "", postamble: "" };
}
return `
const ${timelineVar} = gsap.timeline({ paused: true });
${lines.join("\n")}${mediaSync}
`;
}
function serializeObject(obj: Record<string, number | string>): string {
const entries = Object.entries(obj).map(([key, value]) => {
if (typeof value === "string") {
return `${key}: "${value}"`;
}
return `${key}: ${value}`;
});
return `{ ${entries.join(", ")} }`;
/** Returns true when the parse result is a failure fallback (no animations, no preamble). */
function isParseFailure(parsed: ParsedGsap): boolean {
return parsed.animations.length === 0 && !parsed.preamble;
}
export function updateAnimationInScript(
@@ -302,15 +398,14 @@ export function updateAnimationInScript(
updates: Partial<GsapAnimation>,
): string {
const parsed = parseGsapScript(script);
const updated = parsed.animations.map((anim) => {
if (anim.id === animationId) {
return { ...anim, ...updates };
}
return anim;
if (isParseFailure(parsed)) return script;
const updated = parsed.animations.map((anim) =>
anim.id === animationId ? { ...anim, ...updates } : anim,
);
return serializeGsapAnimations(updated, parsed.timelineVar, {
preamble: parsed.preamble,
postamble: parsed.postamble,
});
return serializeGsapAnimations(updated, parsed.timelineVar);
}
export function addAnimationToScript(
@@ -318,203 +413,25 @@ export function addAnimationToScript(
animation: Omit<GsapAnimation, "id">,
): { script: string; id: string } {
const parsed = parseGsapScript(script);
if (isParseFailure(parsed)) return { script, id: "" };
const id = `anim-${Date.now()}`;
const newAnim: GsapAnimation = { ...animation, id };
parsed.animations.push(newAnim);
const allAnimations = [...parsed.animations, newAnim];
return {
script: serializeGsapAnimations(parsed.animations, parsed.timelineVar),
script: serializeGsapAnimations(allAnimations, parsed.timelineVar, {
preamble: parsed.preamble,
postamble: parsed.postamble,
}),
id,
};
}
export function removeAnimationFromScript(script: string, animationId: string): string {
const parsed = parseGsapScript(script);
if (isParseFailure(parsed)) return script;
const filtered = parsed.animations.filter((a) => a.id !== animationId);
return serializeGsapAnimations(filtered, parsed.timelineVar);
}
export function getAnimationsForElement(
animations: GsapAnimation[],
elementId: string,
): GsapAnimation[] {
const selector = `#${elementId}`;
return animations.filter((a) => a.targetSelector === selector);
}
const FORBIDDEN_GSAP_PATTERNS: Array<{ pattern: RegExp; message: string }> = [
{ pattern: /\.call\s*\(/, message: "call() method not allowed" },
{
pattern: /\.add\s*\(\s*function/,
message: "add(function) not allowed",
},
{
pattern: /\.add\s*\(\s*\(/,
message: "add() with arrow function not allowed",
},
{ pattern: /onComplete\s*:/, message: "onComplete callback not allowed" },
{ pattern: /onStart\s*:/, message: "onStart callback not allowed" },
{ pattern: /onUpdate\s*:/, message: "onUpdate callback not allowed" },
{
pattern: /onRepeat\s*:/,
message: "onRepeat callback not allowed",
},
{
pattern: /onReverseComplete\s*:/,
message: "onReverseComplete callback not allowed",
},
{
pattern: /repeat\s*:\s*-1/,
message: "Infinite repeat (repeat: -1) not allowed",
},
{
pattern: /Math\.random\s*\(/,
message: "Random values (Math.random) not allowed",
},
{
pattern: /Date\.now\s*\(/,
message: "Date-dependent values (Date.now) not allowed",
},
{ pattern: /new\s+Date\s*\(/, message: "Date constructor not allowed" },
{ pattern: /setTimeout\s*\(/, message: "setTimeout not allowed" },
{ pattern: /setInterval\s*\(/, message: "setInterval not allowed" },
{
pattern: /requestAnimationFrame\s*\(/,
message: "requestAnimationFrame not allowed",
},
];
export function validateCompositionGsap(script: string): ValidationResult {
const errors: string[] = [];
const warnings: string[] = [];
for (const { pattern, message } of FORBIDDEN_GSAP_PATTERNS) {
if (pattern.test(script)) {
errors.push(message);
}
}
if (/yoyo\s*:\s*true/.test(script)) {
warnings.push("yoyo animations may behave unexpectedly when scrubbing");
}
if (/stagger\s*:/.test(script)) {
warnings.push("stagger animations may not serialize correctly");
}
return {
valid: errors.length === 0,
errors,
warnings,
};
}
export function keyframesToGsapAnimations(
elementId: string,
keyframes: Keyframe[],
elementStartTime: number,
base?: { x?: number; y?: number; scale?: number },
): GsapAnimation[] {
const sorted = [...keyframes].sort((a, b) => a.time - b.time);
const animations: GsapAnimation[] = [];
const baseX = base?.x ?? 0;
const baseY = base?.y ?? 0;
const baseScale = base?.scale ?? 1;
sorted.forEach((kf, i) => {
const absoluteTime = elementStartTime + kf.time;
const isFirst = i === 0;
const prevKf = i > 0 ? sorted[i - 1] : null;
const duration = prevKf ? kf.time - prevKf.time : undefined;
const position = prevKf ? elementStartTime + prevKf.time : absoluteTime;
const properties: Record<string, number | string> = {};
for (const [key, value] of Object.entries(kf.properties)) {
if (typeof value !== "number") continue;
if (key === "x") properties.x = baseX + value;
else if (key === "y") properties.y = baseY + value;
else if (key === "scale") properties.scale = baseScale * value;
else properties[key] = value;
}
animations.push({
id: `${elementId}-kf-${kf.id}`,
targetSelector: `#${elementId}`,
method: isFirst ? "set" : "to",
position,
properties,
duration: isFirst ? undefined : duration,
ease: kf.ease,
});
return serializeGsapAnimations(filtered, parsed.timelineVar, {
preamble: parsed.preamble,
postamble: parsed.postamble,
});
return animations;
}
export function gsapAnimationsToKeyframes(
animations: GsapAnimation[],
elementStartTime: number,
options?: {
baseX?: number;
baseY?: number;
baseScale?: number;
clampTimeToZero?: boolean;
skipBaseSet?: boolean;
},
): Keyframe[] {
const validMethods: GsapMethod[] = ["set", "to", "from", "fromTo"];
const baseX = options?.baseX ?? 0;
const baseY = options?.baseY ?? 0;
const baseScale = options?.baseScale ?? 1;
const clampTimeToZero = options?.clampTimeToZero ?? true;
const skipBaseSet = options?.skipBaseSet ?? false;
const baseTimeEpsilon = 0.001;
const baseValueEpsilon = 0.00001;
return animations
.filter((a) => validMethods.includes(a.method))
.map((a) => {
const relativeTimeRaw = a.position - elementStartTime;
const time = clampTimeToZero ? Math.max(0, relativeTimeRaw) : relativeTimeRaw;
const properties: Partial<KeyframeProperties> = {};
for (const [key, value] of Object.entries(a.properties)) {
if (SUPPORTED_PROPS.includes(key) && typeof value === "number") {
if (key === "x") {
(properties as Record<string, number>).x = value - baseX;
} else if (key === "y") {
(properties as Record<string, number>).y = value - baseY;
} else if (key === "scale") {
(properties as Record<string, number>).scale =
baseScale !== 0 ? value / baseScale : value;
} else {
(properties as Record<string, number>)[key] = value;
}
}
}
if (skipBaseSet && a.method === "set" && Math.abs(time) <= baseTimeEpsilon) {
const propKeys = Object.keys(properties);
const isOnlyBaseProps = propKeys.every((k) => k === "x" || k === "y" || k === "scale");
if (isOnlyBaseProps && propKeys.length > 0) {
const hasNonBaseOffset =
(properties.x !== undefined && Math.abs(properties.x) > baseValueEpsilon) ||
(properties.y !== undefined && Math.abs(properties.y) > baseValueEpsilon) ||
(properties.scale !== undefined && Math.abs(properties.scale - 1) > baseValueEpsilon);
if (!hasNonBaseOffset) {
return null;
}
}
}
const kf: Keyframe = { id: a.id, time, properties };
if (a.ease !== undefined) kf.ease = a.ease;
return kf;
})
.filter((kf): kf is Keyframe => kf !== null)
.sort((a, b) => a.time - b.time);
}
+287
View File
@@ -0,0 +1,287 @@
/**
* Recast-free GSAP helpers: serialization, keyframe<->animation conversion,
* validation, and shared types.
*
* This module MUST NOT import recast / @babel/parser. It is part of the
* isomorphic core layer that the barrel and browser code depend on. AST
* parsing of GSAP source lives in the Node-only `./gsapParser` module.
*/
import type { Keyframe, KeyframeProperties, ValidationResult } from "../core.types";
export type GsapMethod = "set" | "to" | "from" | "fromTo";
export interface GsapAnimation {
id: string;
targetSelector: string;
method: GsapMethod;
position: number | string;
properties: Record<string, number | string>;
fromProperties?: Record<string, number | string>;
duration?: number;
ease?: string;
/** Non-editable GSAP config (stagger, yoyo, repeat, etc.) preserved for round-trips. */
extras?: Record<string, unknown>;
}
export interface ParsedGsap {
animations: GsapAnimation[];
timelineVar: string;
preamble: string;
postamble: string;
multipleTimelines?: boolean;
unsupportedTimelinePattern?: boolean;
}
export { SUPPORTED_PROPS, SUPPORTED_EASES } from "./gsapConstants";
// ── Serialization ───────────────────────────────────────────────────────────
export function serializeGsapAnimations(
animations: GsapAnimation[],
timelineVar = "tl",
options?: { includeMediaSync?: boolean; preamble?: string; postamble?: string },
): string {
const sorted = [...animations].sort((a, b) => {
const aNum = typeof a.position === "number" ? a.position : Number.MAX_SAFE_INTEGER;
const bNum = typeof b.position === "number" ? b.position : Number.MAX_SAFE_INTEGER;
return aNum - bNum;
});
const lines = sorted.map((anim) => {
const selector = `"${anim.targetSelector}"`;
const props: Record<string, number | string> = { ...anim.properties };
if (anim.duration !== undefined) props.duration = anim.duration;
if (anim.ease) props.ease = anim.ease;
let propsStr = serializeObject(props);
if (anim.extras && Object.keys(anim.extras).length > 0) {
const extrasStr = serializeExtras(anim.extras);
if (Object.keys(props).length === 0) {
propsStr = `{ ${extrasStr} }`;
} else {
// Insert extras before the closing brace
propsStr = propsStr.slice(0, -2) + `, ${extrasStr} }`;
}
}
const posStr = typeof anim.position === "string" ? `"${anim.position}"` : anim.position;
switch (anim.method) {
case "set":
return ` ${timelineVar}.set(${selector}, ${propsStr}, ${posStr});`;
case "to":
return ` ${timelineVar}.to(${selector}, ${propsStr}, ${posStr});`;
case "from":
return ` ${timelineVar}.from(${selector}, ${propsStr}, ${posStr});`;
case "fromTo": {
const fromStr = serializeObject(anim.fromProperties || {});
return ` ${timelineVar}.fromTo(${selector}, ${fromStr}, ${propsStr}, ${posStr});`;
}
}
});
let mediaSync = "";
if (options?.includeMediaSync) {
mediaSync = `
${timelineVar}.eventCallback("onUpdate", function() {
const time = ${timelineVar}.time();
document.querySelectorAll("video[data-start], audio[data-start]").forEach(function(media) {
const start = parseFloat(media.dataset.start);
const end = parseFloat(media.dataset.end) || Infinity;
const mediaTime = time - start;
if (time >= start && time < end) {
if (Math.abs(media.currentTime - mediaTime) > 0.1) {
media.currentTime = mediaTime;
}
if (media.paused && !${timelineVar}.paused()) {
media.play().catch(function() {});
}
} else if (!media.paused) {
media.pause();
}
});
});`;
}
const preamble = options?.preamble || `const ${timelineVar} = gsap.timeline({ paused: true });`;
const postamble = options?.postamble ? `\n ${options.postamble}` : "";
return `
${preamble}
${lines.join("\n")}${mediaSync}${postamble}
`;
}
function serializeValue(value: unknown): string {
if (typeof value === "string" && value.startsWith("__raw:")) {
return value.slice(6);
}
if (typeof value === "string") return JSON.stringify(value);
return String(value);
}
function serializeObject(obj: Record<string, number | string>): string {
const entries = Object.entries(obj).map(([key, value]) => {
const safeKey = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key) ? key : JSON.stringify(key);
return `${safeKey}: ${serializeValue(value)}`;
});
return `{ ${entries.join(", ")} }`;
}
function serializeExtras(extras: Record<string, unknown>): string {
return Object.entries(extras)
.map(([key, value]) => {
const safeKey = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key) ? key : JSON.stringify(key);
return `${safeKey}: ${serializeValue(value)}`;
})
.join(", ");
}
// ── Element filtering ─────────────────────────────────────────────────────────
export function getAnimationsForElement(
animations: GsapAnimation[],
elementId: string,
): GsapAnimation[] {
const selector = `#${elementId}`;
return animations.filter((a) => a.targetSelector === selector);
}
// ── Validation (regex-based, no AST needed) ─────────────────────────────────
const FORBIDDEN_GSAP_PATTERNS: Array<{ pattern: RegExp; message: string }> = [
{ pattern: /\.call\s*\(/, message: "call() method not allowed" },
{ pattern: /\.add\s*\(/, message: "add() method not allowed" },
{ pattern: /\.addLabel\s*\(/, message: "addLabel() method not allowed" },
{ pattern: /\.addPause\s*\(/, message: "addPause() method not allowed" },
{ pattern: /gsap\.registerPlugin\s*\(/, message: "registerPlugin() not allowed" },
{ pattern: /gsap\.registerEffect\s*\(/, message: "registerEffect() not allowed" },
{ pattern: /ScrollTrigger/, message: "ScrollTrigger not allowed" },
{ pattern: /MotionPathPlugin/, message: "MotionPathPlugin not allowed" },
{ pattern: /onComplete\s*:/, message: "onComplete callback not allowed" },
{ pattern: /onUpdate\s*:/, message: "onUpdate callback not allowed" },
{ pattern: /onStart\s*:/, message: "onStart callback not allowed" },
{ pattern: /onRepeat\s*:/, message: "onRepeat callback not allowed" },
{ pattern: /onReverseComplete\s*:/, message: "onReverseComplete callback not allowed" },
{ pattern: /repeat\s*:\s*-1/, message: "Infinite repeat (repeat: -1) not allowed" },
{ pattern: /Math\.random\s*\(/, message: "Random values (Math.random) not allowed" },
{ pattern: /Date\.now\s*\(/, message: "Date-dependent values (Date.now) not allowed" },
{ pattern: /new\s+Date\s*\(/, message: "Date constructor not allowed" },
{ pattern: /setTimeout\s*\(/, message: "setTimeout not allowed" },
{ pattern: /setInterval\s*\(/, message: "setInterval not allowed" },
{ pattern: /requestAnimationFrame\s*\(/, message: "requestAnimationFrame not allowed" },
];
export function validateCompositionGsap(script: string): ValidationResult {
const errors: string[] = [];
const warnings: string[] = [];
for (const { pattern, message } of FORBIDDEN_GSAP_PATTERNS) {
if (pattern.test(script)) errors.push(message);
}
if (/yoyo\s*:\s*true/.test(script)) {
warnings.push("yoyo animations may behave unexpectedly when scrubbing");
}
if (/stagger\s*:/.test(script)) {
warnings.push("stagger animations may not serialize correctly");
}
return { valid: errors.length === 0, errors, warnings };
}
// ── Keyframe Conversion Helpers ─────────────────────────────────────────────
export function keyframesToGsapAnimations(
elementId: string,
keyframes: Keyframe[],
elementStartTime: number,
base?: { x?: number; y?: number; scale?: number },
): GsapAnimation[] {
const sorted = [...keyframes].sort((a, b) => a.time - b.time);
const animations: GsapAnimation[] = [];
const baseX = base?.x ?? 0;
const baseY = base?.y ?? 0;
const baseScale = base?.scale ?? 1;
sorted.forEach((kf, i) => {
const absoluteTime = elementStartTime + kf.time;
const isFirst = i === 0;
const prevKf = i > 0 ? sorted[i - 1] : null;
const duration = prevKf ? kf.time - prevKf.time : undefined;
const position = prevKf ? elementStartTime + prevKf.time : absoluteTime;
const properties: Record<string, number | string> = {};
for (const [key, value] of Object.entries(kf.properties)) {
if (typeof value !== "number") continue;
if (key === "x") properties.x = baseX + value;
else if (key === "y") properties.y = baseY + value;
else if (key === "scale") properties.scale = baseScale * value;
else properties[key] = value;
}
animations.push({
id: `${elementId}-kf-${kf.id}`,
targetSelector: `#${elementId}`,
method: isFirst ? "set" : "to",
position,
properties,
duration: isFirst ? undefined : duration,
ease: kf.ease,
});
});
return animations;
}
export function gsapAnimationsToKeyframes(
animations: GsapAnimation[],
elementStartTime: number,
options?: {
baseX?: number;
baseY?: number;
baseScale?: number;
clampTimeToZero?: boolean;
skipBaseSet?: boolean;
},
): Keyframe[] {
const validMethods: GsapMethod[] = ["set", "to", "from", "fromTo"];
const baseX = options?.baseX ?? 0;
const baseY = options?.baseY ?? 0;
const baseScale = options?.baseScale ?? 1;
const clampTimeToZero = options?.clampTimeToZero ?? true;
const skipBaseSet = options?.skipBaseSet ?? false;
const baseTimeEpsilon = 0.001;
const baseValueEpsilon = 0.00001;
return animations
.filter((a) => validMethods.includes(a.method) && typeof a.position === "number")
.map((a) => {
const relativeTimeRaw = (a.position as number) - elementStartTime;
const time = clampTimeToZero ? Math.max(0, relativeTimeRaw) : relativeTimeRaw;
const properties: Partial<KeyframeProperties> = {};
for (const [key, value] of Object.entries(a.properties)) {
if (typeof value !== "number") continue;
if (key === "x") properties.x = value - baseX;
else if (key === "y") properties.y = value - baseY;
else if (key === "scale") {
properties.scale = baseScale !== 0 ? value / baseScale : value;
} else {
(properties as Record<string, number>)[key] = value;
}
}
if (
skipBaseSet &&
a.method === "set" &&
time < baseTimeEpsilon &&
Object.values(properties).every(
(v) => typeof v === "number" && Math.abs(v) < baseValueEpsilon,
)
) {
return null;
}
return {
id: a.id.replace(/^.*-kf-/, ""),
time,
properties: properties as KeyframeProperties,
ease: a.ease,
};
})
.filter((kf): kf is NonNullable<typeof kf> => kf !== null) as Keyframe[];
}
+1 -96
View File
@@ -10,12 +10,7 @@ import type {
StageZoomKeyframe,
CompositionVariable,
} from "../core.types";
import {
parseGsapScript,
validateCompositionGsap,
gsapAnimationsToKeyframes,
getAnimationsForElement,
} from "./gsapParser";
import { validateCompositionGsap } from "./gsapSerialize";
import type { ValidationResult } from "../core.types";
const MEDIA_TYPES = new Set<string>(["video", "image", "audio"]);
@@ -375,24 +370,6 @@ export function parseHtml(html: string): ParsedHtml {
}
}
// Extract x/y positions and scale from GSAP script
if (gsapScript) {
const positionMap = extractPositionsFromGsap(gsapScript);
for (const element of elements) {
const pos = positionMap.get(element.id);
if (pos) {
if (pos.x !== undefined) element.x = pos.x;
if (pos.y !== undefined) element.y = pos.y;
if (
pos.scale !== undefined &&
(element.type === "video" || element.type === "image" || element.type === "composition")
) {
(element as TimelineMediaElement | TimelineCompositionElement).scale = pos.scale;
}
}
}
}
// Normalize keyframes (clamp negative time, convert absolute -> relative if detected)
for (const element of elements) {
const elementKeyframes = keyframes[element.id];
@@ -428,32 +405,6 @@ export function parseHtml(html: string): ParsedHtml {
const resolution = parseResolutionFromHtml(doc) ?? parseResolutionFromCss(doc, allStyles);
// Extract keyframes from GSAP animations for elements that don't have data-keyframes
if (gsapScript) {
const parsed = parseGsapScript(gsapScript);
for (const element of elements) {
// Only extract from GSAP if we don't have explicit data-keyframes
if (keyframes[element.id]) continue;
const elementAnimations = getAnimationsForElement(parsed.animations, element.id);
if (elementAnimations.length > 0) {
const elementKeyframes = gsapAnimationsToKeyframes(elementAnimations, element.startTime, {
baseX: element.x ?? 0,
baseY: element.y ?? 0,
baseScale:
element.type === "video" || element.type === "image" || element.type === "composition"
? ((element as TimelineMediaElement | TimelineCompositionElement).scale ?? 1)
: 1,
clampTimeToZero: true,
skipBaseSet: true,
});
if (elementKeyframes.length > 0) {
keyframes[element.id] = elementKeyframes;
}
}
}
}
// Parse stage zoom keyframes from zoom container
const stageZoomKeyframes = parseStageZoomKeyframes(doc);
@@ -501,52 +452,6 @@ function parseStageZoomKeyframes(doc: Document): StageZoomKeyframe[] {
return [];
}
/**
* Extract x/y positions and scale from GSAP set() calls at position 0
* Returns a map of elementId -> { x, y, scale }
*/
function extractPositionsFromGsap(
script: string,
): Map<string, { x?: number; y?: number; scale?: number }> {
const positionMap = new Map<string, { x?: number; y?: number; scale?: number }>();
try {
const parsed = parseGsapScript(script);
// Look for set() calls at position 0 with x/y/scale properties
for (const anim of parsed.animations) {
if (anim.method === "set" && anim.position === 0) {
// Extract element ID from selector (e.g., "#element-1" -> "element-1")
const selectorMatch = anim.targetSelector.match(/^#(.+)$/);
if (!selectorMatch) continue;
const elementId = selectorMatch[1] ?? "";
const x = typeof anim.properties.x === "number" ? anim.properties.x : undefined;
const y = typeof anim.properties.y === "number" ? anim.properties.y : undefined;
const scale = typeof anim.properties.scale === "number" ? anim.properties.scale : undefined;
// Only add to map if x, y, or scale is defined and non-default
if (
(x !== undefined && x !== 0) ||
(y !== undefined && y !== 0) ||
(scale !== undefined && scale !== 1)
) {
const existing = positionMap.get(elementId) || {};
positionMap.set(elementId, {
x: x !== undefined ? x : existing.x,
y: y !== undefined ? y : existing.y,
scale: scale !== undefined ? scale : existing.scale,
});
}
}
}
} catch {
// skip GSAP position parsing failure
}
return positionMap;
}
function normalizeKeyframes(
keyframes: Keyframe[],
baseX: number,