Skip to content
Merged
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
108 changes: 108 additions & 0 deletions packages/parsers/src/gsapParser.inline.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { describe, it, expect } from "vitest";
import {
parseGsapScript,
updateAnimationInScript,
addAnimationToScript,
removeAnimationFromScript,
addKeyframeToScript,
removeAllKeyframesFromScript,
} from "./gsapParser.js";
import { addLabelToScript, removeLabelFromScript } from "./gsapWriterAcorn.js";

// U4: recast parser/writer parity for the inline form
// `window.__timelines["scene"] = gsap.timeline()` (the default server write path).

const inlineSrc = `window.__timelines = window.__timelines || {};
window.__timelines["scene"] = gsap.timeline({ paused: true });
window.__timelines["scene"].to("#a", { x: 100, duration: 1 }, 0);
window.__timelines["scene"].to("#b", { y: 50, duration: 1 }, 0.5);`;

describe("recast — inline timeline read", () => {
it("reads inline tweens (double quote)", () => {
const p = parseGsapScript(inlineSrc);
expect(p.unsupportedTimelinePattern).toBeFalsy();
expect(p.animations).toHaveLength(2);
expect(p.animations[0]!.targetSelector).toBe("#a");
});

it("reads single-quote + dot access", () => {
const sq = `window.__timelines['s'] = gsap.timeline();\nwindow.__timelines['s'].to('#a', { x: 1, duration: 1 }, 0);`;
const dot = `window.__timelines.s = gsap.timeline();\nwindow.__timelines.s.to("#a", { x: 1, duration: 1 }, 0);`;
expect(parseGsapScript(sq).animations).toHaveLength(1);
expect(parseGsapScript(dot).animations).toHaveLength(1);
});

it("flags computed key as unsupported", () => {
const c = `const id = "s";\nwindow.__timelines[id] = gsap.timeline();\nwindow.__timelines[id].to("#a", { x: 1, duration: 1 }, 0);`;
expect(parseGsapScript(c).unsupportedTimelinePattern).toBe(true);
});

it("keeps the canonical const form unchanged", () => {
const c = `const tl = gsap.timeline();\nwindow.__timelines["s"] = tl;\ntl.to("#a", { x: 5, duration: 1 }, 0);`;
const p = parseGsapScript(c);
expect(p.timelineVar).toBe("tl");
expect(p.animations).toHaveLength(1);
});
});

describe("recast — inline timeline write", () => {
it("edits an inline tween in place", () => {
const id = parseGsapScript(inlineSrc).animations[0]!.id;
const out = updateAnimationInScript(inlineSrc, id, { properties: { x: 200 } });
expect(out).toContain('window.__timelines["scene"].to("#a"');
expect(out).toContain("200");
expect(parseGsapScript(out).animations).toHaveLength(2);
});

it("adds a tween in member form", () => {
const out = addAnimationToScript(inlineSrc, {
method: "to",
targetSelector: "#c",
properties: { opacity: 1 },
position: 1,
duration: 1,
});
const script = typeof out === "string" ? out : out.script;
expect(script).toContain('window.__timelines["scene"].to("#c"');
expect(parseGsapScript(script).animations).toHaveLength(3);
});

it("removes an inline tween", () => {
const id = parseGsapScript(inlineSrc).animations[1]!.id;
const out = removeAnimationFromScript(inlineSrc, id);
expect(out).not.toContain('"#b"');
expect(parseGsapScript(out).animations).toHaveLength(1);
});

it("adds + removes keyframes on an inline tween", () => {
const id = parseGsapScript(inlineSrc).animations[0]!.id;
const withKf = addKeyframeToScript(inlineSrc, id, 50, { x: 150 });
expect(withKf).toContain("keyframes");
expect(parseGsapScript(withKf).unsupportedTimelinePattern).toBeFalsy();
const kfId = parseGsapScript(withKf).animations[0]!.id;
const cleared = removeAllKeyframesFromScript(withKf, kfId);
expect(cleared).not.toContain("keyframes");
});

it("preserves single-quote member form on write", () => {
const sq = `window.__timelines['s'] = gsap.timeline();\nwindow.__timelines['s'].to('#a', { x: 1, duration: 1 }, 0);`;
const id = parseGsapScript(sq).animations[0]!.id;
const out = updateAnimationInScript(sq, id, { properties: { x: 9 } });
expect(out).toContain("window.__timelines['s']");
});
});

// acorn writer: inline-form label add/remove must match member-rooted callees, not
// just Identifier-rooted ones — else addLabel duplicates and removeLabel no-ops.
describe("acorn — inline timeline labels", () => {
const src = `window.__timelines["scene"] = gsap.timeline({ paused: true });
window.__timelines["scene"].to("#a", { x: 100, duration: 1 }, 0);`;

it("dedups addLabel (moves, not duplicates) and removes it on an inline timeline", () => {
let s = addLabelToScript(src, "intro", 0.5);
s = addLabelToScript(s, "intro", 0.9);
expect((s.match(/addLabel\(/g) ?? []).length).toBe(1);
expect(s).toContain('addLabel("intro", 0.9)');
expect((removeLabelFromScript(s, "intro").match(/addLabel\(/g) ?? []).length).toBe(0);
});
});
102 changes: 79 additions & 23 deletions packages/parsers/src/gsapParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -376,12 +376,54 @@ interface TimelineDefaults {
duration?: number;
}

// `identifier` is the canonical `const tl = …` form; `member` is the inline
// `window.__timelines["scene"] = …` form (the timeline IS the member expression).
type TimelineRef = { kind: "identifier"; name: string } | { kind: "member"; node: AstNode };

interface TimelineDetection {
timelineVar: string | null;
ref: TimelineRef | null;
timelineCount: number;
defaults?: TimelineDefaults;
}

/** The static string key of a member access (`window.__timelines["scene"]` → "scene"), else null. */
function staticMemberKey(node: AstNode): string | null {
if (!node || node.type !== "MemberExpression") return null;
if (node.computed) {
const p = node.property;
if (p?.type === "StringLiteral") return p.value;
if (p?.type === "Literal" && typeof p.value === "string") return p.value;
return null;
}
return node.property?.type === "Identifier" ? node.property.name : null;
}

function isStaticMemberRef(node: AstNode): boolean {
return node?.type === "MemberExpression" && staticMemberKey(node) !== null;
}

/** Structural equality of two member accesses (object chain + static key), quote-insensitive. */
function sameMemberAccess(a: AstNode, b: AstNode): boolean {
if (a?.type !== "MemberExpression" || b?.type !== "MemberExpression") return false;
if (staticMemberKey(a) !== staticMemberKey(b) || staticMemberKey(a) === null) return false;
const ao = a.object;
const bo = b.object;
if (ao?.type === "Identifier" && bo?.type === "Identifier") return ao.name === bo.name;
if (ao?.type === "MemberExpression" && bo?.type === "MemberExpression")
return sameMemberAccess(ao, bo);
return false;
}

/** The source string a tween call roots at: identifier name, or the member source as written. */
function timelineRootSource(ref: TimelineRef): string {
return ref.kind === "identifier" ? ref.name : recast.print(ref.node).code;
}

function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

function extractTimelineDefaults(
callNode: AstNode,
scope: ScopeBindings,
Expand All @@ -401,15 +443,17 @@ function extractTimelineDefaults(

function findTimelineVar(ast: AstNode, scope?: ScopeBindings): TimelineDetection {
let timelineVar: string | null = null;
let ref: TimelineRef | null = null;
let timelineCount = 0;
let defaults: TimelineDefaults | undefined;
const emptyScope: ScopeBindings = scope ?? new Map();
recast.types.visit(ast, {
visitVariableDeclarator(path: AstPath) {
if (isGsapTimelineCall(path.node.init)) {
timelineCount += 1;
if (!timelineVar) {
timelineVar = path.node.id?.name ?? null;
if (!ref && path.node.id?.type === "Identifier") {
timelineVar = path.node.id.name;
ref = { kind: "identifier", name: path.node.id.name };
defaults = extractTimelineDefaults(path.node.init, emptyScope);
}
}
Expand All @@ -418,16 +462,22 @@ function findTimelineVar(ast: AstNode, scope?: ScopeBindings): TimelineDetection
visitAssignmentExpression(path: AstPath) {
if (isGsapTimelineCall(path.node.right)) {
timelineCount += 1;
if (!timelineVar) {
if (!ref) {
const left = path.node.left;
if (left?.type === "Identifier") timelineVar = left.name;
defaults = extractTimelineDefaults(path.node.right, emptyScope);
if (left?.type === "Identifier") {
timelineVar = left.name;
ref = { kind: "identifier", name: left.name };
defaults = extractTimelineDefaults(path.node.right, emptyScope);
} else if (isStaticMemberRef(left)) {
ref = { kind: "member", node: left };
defaults = extractTimelineDefaults(path.node.right, emptyScope);
}
}
}
this.traverse(path);
},
});
return { timelineVar, timelineCount, defaults };
return { timelineVar, ref, timelineCount, defaults };
}

// ── Find All Tween Calls ────────────────────────────────────────────────────
Expand All @@ -448,17 +498,18 @@ interface TweenCallInfo {
* True when the member chain of `callNode.callee` is rooted at the timeline
* variable — `tl.to(...)` and every link of a chain `tl.to(...).to(...)`.
*/
function isTimelineRootedCall(callNode: AstNode, timelineVar: string): boolean {
function isTimelineRootedCall(callNode: AstNode, ref: TimelineRef): boolean {
let obj = callNode.callee?.object;
while (obj?.type === "CallExpression") {
obj = obj.callee?.object;
}
return obj?.type === "Identifier" && obj.name === timelineVar;
if (ref.kind === "identifier") return obj?.type === "Identifier" && obj.name === ref.name;
return sameMemberAccess(obj, ref.node);
}

function findAllTweenCalls(
ast: AstNode,
timelineVar: string,
ref: TimelineRef,
scope: ScopeBindings,
targetBindings: TargetBindings,
): TweenCallInfo[] {
Expand All @@ -484,7 +535,7 @@ function findAllTweenCalls(
if (
callee?.type === "MemberExpression" &&
callee.property?.type === "Identifier" &&
(isTimelineRootedCall(node, timelineVar) || isGlobalSet)
(isTimelineRootedCall(node, ref) || isGlobalSet)
) {
const method = callee.property.name;
if (!GSAP_METHODS.has(method)) {
Expand Down Expand Up @@ -1131,8 +1182,9 @@ function parseGsapAst(script: string): ParsedGsapAst {
const scope = collectScopeBindings(ast);
const targetBindings = collectTargetBindings(ast, scope);
const detection = findTimelineVar(ast, scope);
const timelineVar = detection.timelineVar ?? "tl";
const calls = findAllTweenCalls(ast, timelineVar, scope, targetBindings);
const ref: TimelineRef = detection.ref ?? { kind: "identifier", name: "tl" };
const timelineVar = timelineRootSource(ref);
const calls = findAllTweenCalls(ast, ref, scope, targetBindings);
sortBySourcePosition(calls);
const rawAnims = calls.map((call) => tweenCallToAnimation(call, scope));
applyTimelineDefaults(rawAnims, detection.defaults);
Expand All @@ -1151,15 +1203,19 @@ function parseGsapAst(script: string): ParsedGsapAst {
export function parseGsapScript(script: string): ParsedGsap {
try {
const { detection, timelineVar, located } = parseGsapAst(script);
const ref: TimelineRef = detection.ref ?? { kind: "identifier", name: "tl" };
const animations = located.map((l) => l.animation);

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 declPattern =
ref.kind === "identifier"
? `(?:const|let|var)\\s+${timelineVar}\\s*=\\s*gsap\\.timeline\\s*\\([^)]*\\)\\s*;?`
: `${escapeRegExp(timelineVar)}\\s*=\\s*gsap\\.timeline\\s*\\([^)]*\\)\\s*;?`;
const timelineMatch = script.match(new RegExp(`^[\\s\\S]*?${declPattern}`));
const fallbackPreamble =
ref.kind === "identifier"
? `const ${timelineVar} = gsap.timeline({ paused: true });`
: `${timelineVar} = gsap.timeline({ paused: true });`;
const preamble = timelineMatch?.[0] ?? fallbackPreamble;

const lastCallIdx = script.lastIndexOf(`${timelineVar}.`);
let postamble = "";
Expand All @@ -1173,7 +1229,7 @@ export function parseGsapScript(script: string): ParsedGsap {

const result: ParsedGsap = { animations, timelineVar, preamble, postamble };
if (detection.timelineCount > 1) result.multipleTimelines = true;
if (detection.timelineCount > 0 && detection.timelineVar === null)
if (detection.timelineCount > 0 && detection.ref === null)
result.unsupportedTimelinePattern = true;
return result;
} catch {
Expand Down Expand Up @@ -1468,7 +1524,7 @@ export function addAnimationToScript(
return { script, id: "" };
}
// Nothing to anchor against and no timeline to target — treat as parse failure.
if (parsed.located.length === 0 && parsed.detection.timelineVar === null) {
if (parsed.located.length === 0 && parsed.detection.ref === null) {
return { script, id: "" };
}

Expand Down Expand Up @@ -1500,7 +1556,7 @@ export function addAnimationWithKeyframesToScript(
console.warn("[gsap-parser] addAnimationWithKeyframesToScript parse failed:", e);
return { script, id: "" };
}
if (parsed.located.length === 0 && parsed.detection.timelineVar === null) {
if (parsed.located.length === 0 && parsed.detection.ref === null) {
return { script, id: "" };
}

Expand Down Expand Up @@ -2796,7 +2852,7 @@ export function addMotionPathToScript(
console.warn("[gsap-parser] addMotionPathToScript parse failed:", e);
return { script, id: null };
}
if (parsed.located.length === 0 && parsed.detection.timelineVar === null) {
if (parsed.located.length === 0 && parsed.detection.ref === null) {
return { script, id: null };
}

Expand Down
75 changes: 75 additions & 0 deletions packages/parsers/src/gsapParserAcorn.inline.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { describe, it, expect } from "vitest";
import { parseGsapScriptAcorn } from "./gsapParserAcorn.js";

// U1+U2: the editor must read timelines authored inline as
// `window.__timelines["id"] = gsap.timeline()` — not just the canonical
// `const tl = gsap.timeline(); window.__timelines[id] = tl` form.

const wrap = (decl: string, tweens: string) =>
`window.__timelines = window.__timelines || {};\n${decl}\n${tweens}`;

describe("inline timeline assignment — read", () => {
it("reads tweens from a double-quoted inline timeline", () => {
const src = wrap(
`window.__timelines["scene"] = gsap.timeline({ paused: true });`,
`window.__timelines["scene"].to("#a", { x: 100, duration: 1 }, 0);\n` +
`window.__timelines["scene"].to("#b", { y: 50, duration: 1 }, 0.5);`,
);
const parsed = parseGsapScriptAcorn(src);
expect(parsed.unsupportedTimelinePattern).toBeFalsy();
expect(parsed.animations).toHaveLength(2);
expect(parsed.animations[0]!.targetSelector).toBe("#a");
expect(parsed.animations[1]!.targetSelector).toBe("#b");
});

it("reads a single-quoted inline timeline", () => {
const src = wrap(
`window.__timelines['scene'] = gsap.timeline();`,
`window.__timelines['scene'].to('#a', { x: 10, duration: 1 }, 0);`,
);
const parsed = parseGsapScriptAcorn(src);
expect(parsed.unsupportedTimelinePattern).toBeFalsy();
expect(parsed.animations).toHaveLength(1);
expect(parsed.animations[0]!.targetSelector).toBe("#a");
});

it("reads a static dot-access inline timeline", () => {
const src = wrap(
`window.__timelines.scene = gsap.timeline();`,
`window.__timelines.scene.to("#a", { x: 10, duration: 1 }, 0);`,
);
const parsed = parseGsapScriptAcorn(src);
expect(parsed.unsupportedTimelinePattern).toBeFalsy();
expect(parsed.animations).toHaveLength(1);
});

it("flags a computed-key timeline as unsupported (cannot statically resolve)", () => {
const src = wrap(
`const id = "scene";\nwindow.__timelines[id] = gsap.timeline();`,
`window.__timelines[id].to("#a", { x: 10, duration: 1 }, 0);`,
);
const parsed = parseGsapScriptAcorn(src);
expect(parsed.unsupportedTimelinePattern).toBe(true);
});

it("does not cross-attribute tweens of a different member slot", () => {
const src = wrap(
`window.__timelines["a"] = gsap.timeline();\nwindow.__timelines["b"] = gsap.timeline();`,
`window.__timelines["a"].to("#a", { x: 1, duration: 1 }, 0);\n` +
`window.__timelines["b"].to("#b", { x: 2, duration: 1 }, 0);`,
);
const parsed = parseGsapScriptAcorn(src);
// First detected timeline is "a"; only its tween should be attributed here.
expect(parsed.multipleTimelines).toBe(true);
expect(parsed.animations.some((a) => a.targetSelector === "#a")).toBe(true);
expect(parsed.animations.every((a) => a.targetSelector !== "#b")).toBe(true);
});

it("leaves the canonical const form working", () => {
const src = `const tl = gsap.timeline();\nwindow.__timelines["scene"] = tl;\ntl.to("#a", { x: 5, duration: 1 }, 0);`;
const parsed = parseGsapScriptAcorn(src);
expect(parsed.unsupportedTimelinePattern).toBeFalsy();
expect(parsed.animations).toHaveLength(1);
expect(parsed.timelineVar).toBe("tl");
});
});
Loading
Loading