Skip to content
Open
28 changes: 19 additions & 9 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 11 additions & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@
"import": "./src/registry/index.ts",
"types": "./src/registry/index.ts"
},
"./gsap-parser": {
"import": "./src/parsers/gsapParser.ts",
"types": "./src/parsers/gsapParser.ts"
},
"./schemas/registry.json": "./schemas/registry.json",
"./schemas/registry-item.json": "./schemas/registry-item.json"
},
Expand Down Expand Up @@ -105,6 +109,10 @@
"import": "./dist/registry/index.js",
"types": "./dist/registry/index.d.ts"
},
"./gsap-parser": {
"import": "./dist/parsers/gsapParser.js",
"types": "./dist/parsers/gsapParser.d.ts"
},
"./schemas/registry.json": "./schemas/registry.json",
"./schemas/registry-item.json": "./schemas/registry-item.json"
},
Expand Down Expand Up @@ -134,8 +142,10 @@
"prepublishOnly": "echo skip"
},
"dependencies": {
"@babel/parser": "^7.27.0",
"@chenglou/pretext": "^0.0.5",
"postcss": "^8.5.8"
"postcss": "^8.5.8",
"recast": "^0.23.11"
},
"devDependencies": {
"@types/jsdom": "^28.0.0",
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/lint/rules/gsap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,9 @@ function extractGsapWindows(script: string): GsapWindow[] {
const animation = parsed.animations[index];
index += 1;
if (!animation) continue;
// Skip animations with string positions (e.g. "+=1", "<") — their absolute
// timing depends on runtime evaluation and can't be statically linted.
if (typeof animation.position !== "number") continue;
windows.push({
targetSelector: animation.targetSelector,
position: animation.position,
Expand Down
151 changes: 141 additions & 10 deletions packages/core/src/parsers/gsapParser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand All @@ -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", () => {
Expand Down Expand Up @@ -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);
Expand All @@ -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", () => {
Expand All @@ -175,6 +174,62 @@ 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("skips unresolvable references without crashing", () => {
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).toBeUndefined();
});
});

describe("gsapAnimationsToKeyframes", () => {
Expand Down Expand Up @@ -244,7 +299,7 @@ describe("gsapAnimationsToKeyframes", () => {
targetSelector: "#el1",
method: "set",
position: 5,
properties: { x: 0, y: 0, scale: 1 },
properties: { x: 0, y: 0 },
},
{
id: "anim-2",
Expand All @@ -258,7 +313,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");
});
Expand Down Expand Up @@ -527,6 +581,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");
Expand Down
Loading
Loading