Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,14 @@
"import": "./src/parsers/gsapParser.ts",
"types": "./src/parsers/gsapParser.ts"
},
"./gsap-parser-acorn": {
"import": "./src/parsers/gsapParserAcorn.ts",
"types": "./src/parsers/gsapParserAcorn.ts"
},
"./gsap-writer-acorn": {
"import": "./src/parsers/gsapWriterAcorn.ts",
"types": "./src/parsers/gsapWriterAcorn.ts"
},
"./gsap-constants": {
"import": "./src/parsers/gsapConstants.ts",
"types": "./src/parsers/gsapConstants.ts"
Expand Down Expand Up @@ -153,6 +161,14 @@
"import": "./dist/parsers/gsapParser.js",
"types": "./dist/parsers/gsapParser.d.ts"
},
"./gsap-parser-acorn": {
"import": "./dist/parsers/gsapParserAcorn.js",
"types": "./dist/parsers/gsapParserAcorn.d.ts"
},
"./gsap-writer-acorn": {
"import": "./dist/parsers/gsapWriterAcorn.js",
"types": "./dist/parsers/gsapWriterAcorn.d.ts"
},
"./gsap-constants": {
"import": "./dist/parsers/gsapConstants.js",
"types": "./dist/parsers/gsapConstants.d.ts"
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/parsers/gsapParser.acorn.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// fallow-ignore-file duplication
// fallow-ignore-file code-duplication
/**
* T6b — acorn vs golden differential harness.
*
Expand Down
139 changes: 138 additions & 1 deletion packages/core/src/parsers/gsapParserAcorn.full.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// fallow-ignore-file duplication
// fallow-ignore-file code-duplication
/**
* T6d: parse-parity suite — runs the full gsapParser.test.ts parse scenarios
* against parseGsapScriptAcorn. Write-path tests are it.skip'd; those live
Expand Down Expand Up @@ -912,3 +912,140 @@ describe("native GSAP keyframes parsing", () => {
expect(Object.keys(anim.properties)).toHaveLength(0);
});
});

// ── motionPath parsing ────────────────────────────────────────────────────────

describe("motionPath parsing", () => {
it("parses motionPath with waypoint array and curviness", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el", {
motionPath: {
path: [{x: 0, y: 0}, {x: 200, y: -100}, {x: 400, y: 50}],
curviness: 1.5
},
duration: 2
}, 0);
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(1);
const anim = result.animations[0];

expect(anim.arcPath).toBeDefined();
expect(anim.arcPath!.enabled).toBe(true);
expect(anim.arcPath!.segments).toHaveLength(2);
expect(anim.arcPath!.segments[0].curviness).toBe(1.5);
expect(anim.arcPath!.segments[1].curviness).toBe(1.5);

expect(anim.keyframes).toBeDefined();
expect(anim.keyframes!.keyframes).toHaveLength(3);
expect(anim.keyframes!.keyframes[0].properties.x).toBe(0);
expect(anim.keyframes!.keyframes[0].properties.y).toBe(0);
expect(anim.keyframes!.keyframes[1].properties.x).toBe(200);
expect(anim.keyframes!.keyframes[1].properties.y).toBe(-100);
expect(anim.keyframes!.keyframes[2].properties.x).toBe(400);
expect(anim.keyframes!.keyframes[2].properties.y).toBe(50);
});

it("parses motionPath with type cubic and explicit control points", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el", {
motionPath: {
path: [
{x: 0, y: 0},
{x: 50, y: -80}, {x: 150, y: -120},
{x: 200, y: -100},
{x: 250, y: -80}, {x: 350, y: 30},
{x: 400, y: 50}
],
type: "cubic"
},
duration: 2
}, 0);
`;
const result = parseGsapScript(script);
const anim = result.animations[0];

expect(anim.arcPath).toBeDefined();
expect(anim.arcPath!.segments).toHaveLength(2);

expect(anim.arcPath!.segments[0].cp1).toEqual({ x: 50, y: -80 });
expect(anim.arcPath!.segments[0].cp2).toEqual({ x: 150, y: -120 });

expect(anim.arcPath!.segments[1].cp1).toEqual({ x: 250, y: -80 });
expect(anim.arcPath!.segments[1].cp2).toEqual({ x: 350, y: 30 });

expect(anim.keyframes!.keyframes).toHaveLength(3);
expect(anim.keyframes!.keyframes[0].properties).toEqual({ x: 0, y: 0 });
expect(anim.keyframes!.keyframes[1].properties).toEqual({ x: 200, y: -100 });
expect(anim.keyframes!.keyframes[2].properties).toEqual({ x: 400, y: 50 });
});

it("parses motionPath with autoRotate", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el", {
motionPath: {
path: [{x: 0, y: 0}, {x: 200, y: 100}],
autoRotate: true
},
duration: 1
}, 0);
`;
const result = parseGsapScript(script);
const anim = result.animations[0];
expect(anim.arcPath!.autoRotate).toBe(true);
});

it("merges motionPath waypoints into existing keyframes", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el", {
motionPath: {
path: [{x: 0, y: 0}, {x: 200, y: 100}],
curviness: 2
},
keyframes: {
"0%": { opacity: 1 },
"100%": { opacity: 0 }
},
duration: 2
}, 0);
`;
const result = parseGsapScript(script);
const anim = result.animations[0];

expect(anim.arcPath).toBeDefined();
expect(anim.arcPath!.segments).toHaveLength(1);
expect(anim.arcPath!.segments[0].curviness).toBe(2);

expect(anim.keyframes!.keyframes).toHaveLength(2);
expect(anim.keyframes!.keyframes[0].properties).toEqual({ opacity: 1, x: 0, y: 0 });
expect(anim.keyframes!.keyframes[1].properties).toEqual({ opacity: 0, x: 200, y: 100 });
});

it("skips motionPath with fewer than 2 waypoints", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el", {
motionPath: { path: [{x: 0, y: 0}] },
duration: 1
}, 0);
`;
const result = parseGsapScript(script);
expect(result.animations[0].arcPath).toBeUndefined();
});

it("tween without motionPath parses identically to before", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el", { x: 100, y: 200, duration: 1 }, 0);
`;
const result = parseGsapScript(script);
const anim = result.animations[0];
expect(anim.arcPath).toBeUndefined();
expect(anim.properties.x).toBe(100);
expect(anim.properties.y).toBe(200);
});
});
5 changes: 3 additions & 2 deletions packages/core/src/parsers/gsapParserAcorn.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// fallow-ignore-file duplication
// fallow-ignore-file code-duplication
/**
* Browser-safe GSAP read path — acorn + acorn-walk.
*
Expand Down Expand Up @@ -1046,6 +1046,7 @@ function assignStableIds(anims: Omit<GsapAnimation, "id">[]): GsapAnimation[] {
export interface ParsedGsapAcornForWrite {
ast: any;
timelineVar: string;
hasTimeline: boolean;
located: Array<{ id: string; call: TweenCallInfo; animation: GsapAnimation }>;
}

Expand Down Expand Up @@ -1075,7 +1076,7 @@ export function parseGsapScriptAcornForWrite(script: string): ParsedGsapAcornFor
call,
animation: animations[i]!,
}));
return { ast, timelineVar, located };
return { ast, timelineVar, hasTimeline: detection.timelineVar !== null, located };
} catch {
return null;
}
Expand Down
74 changes: 39 additions & 35 deletions packages/core/src/parsers/gsapSerialize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ export function serializeGsapAnimations(
b.resolvedStart ?? (typeof b.position === "number" ? b.position : Number.MAX_SAFE_INTEGER);
return aNum - bNum;
});
// fallow-ignore-next-line complexity
const lines = sorted.map((anim) => {
const selector = `"${anim.targetSelector}"`;
const props: Record<string, number | string> = { ...anim.properties };
Expand Down Expand Up @@ -198,7 +199,6 @@ export function getAnimationsForElementId(
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\.registerEffect\s*\(/, message: "registerEffect() not allowed" },
{ pattern: /ScrollTrigger/, message: "ScrollTrigger not allowed" },
Expand Down Expand Up @@ -245,6 +245,7 @@ export function keyframesToGsapAnimations(
const baseY = base?.y ?? 0;
const baseScale = base?.scale ?? 1;

// fallow-ignore-next-line complexity
sorted.forEach((kf, i) => {
const absoluteTime = elementStartTime + kf.time;
const isFirst = i === 0;
Expand Down Expand Up @@ -295,41 +296,44 @@ export function gsapAnimationsToKeyframes(
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;
return (
animations
.filter((a) => validMethods.includes(a.method) && typeof a.position === "number")
// fallow-ignore-next-line complexity
.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;
}
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[];
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[]
);
}
2 changes: 1 addition & 1 deletion packages/core/src/parsers/gsapWriter.acorn.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// fallow-ignore-file duplication
// fallow-ignore-file code-duplication
/**
* T6c — acorn write path with magic-string offset-splice.
*
Expand Down
Loading
Loading