Skip to content

Commit 0fbda8a

Browse files
authored
feat(core): acorn GSAP write path — magic-string offset-splice (T6c) (#1369)
1 parent be4a28a commit 0fbda8a

5 files changed

Lines changed: 695 additions & 1 deletion

File tree

bun.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/core/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,7 @@
216216
"acorn": "^8.17.0",
217217
"acorn-walk": "^8.3.5",
218218
"bpm-detective": "^2.0.5",
219+
"magic-string": "^0.30.21",
219220
"postcss": "^8.5.8",
220221
"postcss-selector-parser": "^7.1.2",
221222
"recast": "^0.23.11"

packages/core/src/parsers/gsapParserAcorn.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -419,7 +419,7 @@ const EXTRAS_KEYS = new Set([
419419
"immediateRender",
420420
]);
421421

422-
interface TweenCallInfo {
422+
export interface TweenCallInfo {
423423
node: any;
424424
/** acorn-walk ancestor array at the call site (root→call, call is last). */
425425
ancestors: any[];
@@ -1041,6 +1041,46 @@ function assignStableIds(anims: Omit<GsapAnimation, "id">[]): GsapAnimation[] {
10411041
});
10421042
}
10431043

1044+
// ── Write-path internal parse ─────────────────────────────────────────────────
1045+
1046+
export interface ParsedGsapAcornForWrite {
1047+
ast: any;
1048+
timelineVar: string;
1049+
located: Array<{ id: string; call: TweenCallInfo; animation: GsapAnimation }>;
1050+
}
1051+
1052+
/**
1053+
* Parse a GSAP script and return internal AST + call nodes for the write path.
1054+
* Consumed by gsapWriterAcorn.ts (magic-string offset-splice).
1055+
*/
1056+
export function parseGsapScriptAcornForWrite(script: string): ParsedGsapAcornForWrite | null {
1057+
try {
1058+
const ast = acorn.parse(script, {
1059+
ecmaVersion: "latest",
1060+
sourceType: "script",
1061+
locations: true,
1062+
});
1063+
const scope = collectScopeBindings(ast);
1064+
const targetBindings = collectTargetBindings(ast, scope);
1065+
const detection = findTimelineVar(ast, scope);
1066+
const timelineVar = detection.timelineVar ?? "tl";
1067+
const calls = findAllTweenCalls(ast, timelineVar, scope, targetBindings);
1068+
sortBySourcePosition(calls);
1069+
const rawAnims = calls.map((call) => tweenCallToAnimation(call, scope, script));
1070+
applyTimelineDefaults(rawAnims, detection.defaults);
1071+
resolveTimelinePositions(rawAnims);
1072+
const animations = assignStableIds(rawAnims);
1073+
const located = calls.map((call, i) => ({
1074+
id: animations[i]!.id,
1075+
call,
1076+
animation: animations[i]!,
1077+
}));
1078+
return { ast, timelineVar, located };
1079+
} catch {
1080+
return null;
1081+
}
1082+
}
1083+
10441084
// ── Public API ────────────────────────────────────────────────────────────────
10451085

10461086
/**
Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
// fallow-ignore-file duplication
2+
/**
3+
* T6c — acorn write path with magic-string offset-splice.
4+
*
5+
* Verifies that each write op touches only the intended byte span and leaves
6+
* every other character identical to the original source.
7+
*/
8+
import { describe, expect, it } from "vitest";
9+
import {
10+
addAnimationToScript,
11+
addKeyframeToScript,
12+
removeAnimationFromScript,
13+
removeKeyframeFromScript,
14+
updateAnimationInScript,
15+
updateKeyframeInScript,
16+
} from "./gsapWriterAcorn.js";
17+
18+
// ---------------------------------------------------------------------------
19+
// Fixture scripts
20+
// ---------------------------------------------------------------------------
21+
22+
const SCRIPT_A = `\
23+
var tl = gsap.timeline({ paused: true });
24+
tl.to("#hero", { opacity: 1, duration: 0.5, ease: "power3.out" }, 0.2);
25+
window.__timelines["t"] = tl;`;
26+
27+
const SCRIPT_B = `\
28+
var tl = gsap.timeline({ paused: true });
29+
tl.to("#hero", { opacity: 1, duration: 0.5, ease: "power3.out" }, 0);
30+
tl.to("#hero", { opacity: 0, duration: 0.3, ease: "power3.in" }, 1);
31+
window.__timelines["t"] = tl;`;
32+
33+
const SCRIPT_C = `\
34+
var tl = gsap.timeline({ paused: true });
35+
tl.from(".a", { opacity: 0, duration: 0.5 }, 0)
36+
.from(".b", { opacity: 0, duration: 0.3 }, 0.5);
37+
window.__timelines["t"] = tl;`;
38+
39+
// 3-keyframe script so removal leaves ≥2 kfs (no collapse needed)
40+
const SCRIPT_D = `\
41+
var tl = gsap.timeline({ paused: true });
42+
tl.to("#box", { keyframes: { "0%": { opacity: 0 }, "50%": { opacity: 0.7 }, "100%": { opacity: 1 } }, duration: 0.5 }, 0.2);
43+
window.__timelines["t"] = tl;`;
44+
45+
// ---------------------------------------------------------------------------
46+
// No-op identity
47+
// ---------------------------------------------------------------------------
48+
49+
describe("T6c — no-op identity", () => {
50+
it("updateAnimationInScript with empty updates returns identical script", () => {
51+
const result = updateAnimationInScript(SCRIPT_A, "#hero-to-200-visual", {});
52+
expect(result).toBe(SCRIPT_A);
53+
});
54+
55+
it("updateAnimationInScript with unknown ID returns identical script", () => {
56+
const result = updateAnimationInScript(SCRIPT_A, "not-a-real-id", { ease: "power2.in" });
57+
expect(result).toBe(SCRIPT_A);
58+
});
59+
});
60+
61+
// ---------------------------------------------------------------------------
62+
// updateAnimationInScript
63+
// ---------------------------------------------------------------------------
64+
65+
describe("T6c — updateAnimationInScript", () => {
66+
it("updates ease value in-place", () => {
67+
const result = updateAnimationInScript(SCRIPT_A, "#hero-to-200-visual", {
68+
ease: "power2.in",
69+
});
70+
expect(result).toContain('"power2.in"');
71+
expect(result).not.toContain('"power3.out"');
72+
// Preamble + postamble unchanged
73+
expect(result).toContain("var tl = gsap.timeline({ paused: true });");
74+
expect(result).toContain('window.__timelines["t"] = tl;');
75+
});
76+
77+
it("updates duration value in-place", () => {
78+
const result = updateAnimationInScript(SCRIPT_A, "#hero-to-200-visual", {
79+
duration: 1.2,
80+
});
81+
expect(result).toContain("duration: 1.2");
82+
expect(result).not.toContain("duration: 0.5");
83+
expect(result).toContain('"power3.out"');
84+
});
85+
86+
it("updates position arg in-place", () => {
87+
const result = updateAnimationInScript(SCRIPT_A, "#hero-to-200-visual", {
88+
position: 0.5,
89+
});
90+
expect(result).toContain("}, 0.5)");
91+
expect(result).not.toContain("}, 0.2)");
92+
expect(result).toContain("opacity: 1");
93+
});
94+
95+
it("inserts ease when property was absent", () => {
96+
const noEase = `\
97+
var tl = gsap.timeline({ paused: true });
98+
tl.to("#hero", { opacity: 1, duration: 0.5 }, 0.2);
99+
window.__timelines["t"] = tl;`;
100+
const result = updateAnimationInScript(noEase, "#hero-to-200-visual", {
101+
ease: "power3.out",
102+
});
103+
expect(result).toContain('ease: "power3.out"');
104+
// Duration, opacity, position unchanged
105+
expect(result).toContain("duration: 0.5");
106+
expect(result).toContain("opacity: 1");
107+
expect(result).toContain("}, 0.2)");
108+
});
109+
110+
it("updates fromTo — ease on toVars", () => {
111+
const fromTo = `\
112+
var tl = gsap.timeline({ paused: true });
113+
tl.fromTo("#hero", { opacity: 0 }, { opacity: 1, duration: 0.5, ease: "power3.out" }, 0.1);
114+
window.__timelines["t"] = tl;`;
115+
// ID: target="#hero", method="fromTo", pos=0.1 → posKey=100, propertyGroup=visual
116+
const result = updateAnimationInScript(fromTo, "#hero-fromTo-100-visual", {
117+
ease: "back.out",
118+
});
119+
expect(result).toContain('"back.out"');
120+
expect(result).not.toContain('"power3.out"');
121+
expect(result).toContain("opacity: 0");
122+
});
123+
124+
it("byte-identity outside edited ease span", () => {
125+
const result = updateAnimationInScript(SCRIPT_A, "#hero-to-200-visual", {
126+
ease: "power2.in",
127+
});
128+
const oldEaseStart = SCRIPT_A.indexOf('"power3.out"');
129+
const newEaseStart = result.indexOf('"power2.in"');
130+
// Everything before the ease value is identical
131+
expect(result.slice(0, newEaseStart)).toBe(SCRIPT_A.slice(0, oldEaseStart));
132+
// Everything after the ease value close-quote is identical
133+
const oldAfter = SCRIPT_A.slice(oldEaseStart + '"power3.out"'.length);
134+
const newAfter = result.slice(newEaseStart + '"power2.in"'.length);
135+
expect(newAfter).toBe(oldAfter);
136+
});
137+
});
138+
139+
// ---------------------------------------------------------------------------
140+
// removeAnimationFromScript
141+
// ---------------------------------------------------------------------------
142+
143+
describe("T6c — removeAnimationFromScript", () => {
144+
it("removes a standalone tween statement", () => {
145+
const result = removeAnimationFromScript(SCRIPT_B, "#hero-to-0-visual");
146+
expect(result).not.toContain("power3.out");
147+
expect(result).toContain("power3.in");
148+
expect(result).toContain('window.__timelines["t"] = tl;');
149+
});
150+
151+
it("removes last chain link (outer call)", () => {
152+
// SCRIPT_C: tl.from(".a",...,0).from(".b",...,0.5)
153+
// Remove .b (outermost call = last in source)
154+
const result = removeAnimationFromScript(SCRIPT_C, ".b-from-500-visual");
155+
expect(result).toContain('.from(".a"');
156+
expect(result).not.toContain('.from(".b"');
157+
// The statement should still end with ; (no dangling chain)
158+
expect(result).toContain("}, 0);");
159+
});
160+
161+
it("removes inner chain link", () => {
162+
// SCRIPT_C: tl.from(".a",...,0).from(".b",...,0.5)
163+
// Remove .a (innermost call = first in source)
164+
const result = removeAnimationFromScript(SCRIPT_C, ".a-from-0-visual");
165+
expect(result).not.toContain('.from(".a"');
166+
expect(result).toContain('.from(".b"');
167+
// Chain is still rooted at tl (whitespace between tl and .from is valid JS)
168+
expect(result).toMatch(/tl[\s.]*from\("\.b"/);
169+
});
170+
171+
it("unknown ID returns script unchanged", () => {
172+
const result = removeAnimationFromScript(SCRIPT_A, "nonexistent-id");
173+
expect(result).toBe(SCRIPT_A);
174+
});
175+
});
176+
177+
// ---------------------------------------------------------------------------
178+
// addAnimationToScript
179+
// ---------------------------------------------------------------------------
180+
181+
describe("T6c — addAnimationToScript", () => {
182+
it("inserts new tween after last existing tween", () => {
183+
const { script: result } = addAnimationToScript(SCRIPT_A, {
184+
targetSelector: "#new",
185+
method: "to",
186+
position: 0.5,
187+
duration: 0.3,
188+
properties: { x: 100 },
189+
});
190+
expect(result).toContain('tl.to("#new"');
191+
expect(result).toContain("x: 100");
192+
expect(result).toContain("duration: 0.3");
193+
// Original content preserved
194+
expect(result).toContain('tl.to("#hero"');
195+
expect(result).toContain('window.__timelines["t"] = tl;');
196+
// New tween comes after hero tween
197+
expect(result.indexOf('tl.to("#new"')).toBeGreaterThan(result.indexOf('tl.to("#hero"'));
198+
});
199+
200+
it("returns a non-empty stable id for the new animation", () => {
201+
const { id } = addAnimationToScript(SCRIPT_A, {
202+
targetSelector: "#new",
203+
method: "to",
204+
position: 0.5,
205+
duration: 0.3,
206+
properties: { x: 100 },
207+
});
208+
expect(id).toBeTruthy();
209+
expect(typeof id).toBe("string");
210+
});
211+
212+
it("inserts after timeline declaration when script has no tweens", () => {
213+
const empty = `var tl = gsap.timeline({ paused: true });\nwindow.__timelines["t"] = tl;`;
214+
const { script: result } = addAnimationToScript(empty, {
215+
targetSelector: "#hero",
216+
method: "to",
217+
position: 0,
218+
duration: 0.5,
219+
properties: { opacity: 1 },
220+
});
221+
expect(result).toContain('tl.to("#hero"');
222+
// Inserted after timeline declaration
223+
expect(result.indexOf('tl.to("#hero"')).toBeGreaterThan(result.indexOf("gsap.timeline"));
224+
});
225+
});
226+
227+
// ---------------------------------------------------------------------------
228+
// Keyframe write ops
229+
// ---------------------------------------------------------------------------
230+
231+
describe("T6c — keyframe write ops", () => {
232+
it("updateKeyframeInScript replaces keyframe value at given percentage", () => {
233+
// Update 50% from { opacity: 0.7 } to { opacity: 0.5 }
234+
const result = updateKeyframeInScript(SCRIPT_D, "#box-to-200-visual", 50, { opacity: 0.5 });
235+
expect(result).toContain("opacity: 0.5");
236+
expect(result).not.toContain("opacity: 0.7");
237+
// Other keyframes unchanged
238+
expect(result).toContain('"0%": { opacity: 0 }');
239+
expect(result).toContain('"100%": { opacity: 1 }');
240+
});
241+
242+
it("updateKeyframeInScript preserves bytes outside the edited value", () => {
243+
const result = updateKeyframeInScript(SCRIPT_D, "#box-to-200-visual", 100, {
244+
opacity: 0.9,
245+
});
246+
// The 50% keyframe is untouched
247+
expect(result).toContain('"50%": { opacity: 0.7 }');
248+
// Duration and position are unchanged
249+
expect(result).toContain("duration: 0.5");
250+
expect(result).toContain("}, 0.2)");
251+
});
252+
253+
it("addKeyframeToScript inserts new percentage in sorted order", () => {
254+
const result = addKeyframeToScript(SCRIPT_D, "#box-to-200-visual", 25, { opacity: 0.3 });
255+
expect(result).toContain('"25%"');
256+
expect(result).toContain("opacity: 0.3");
257+
// Original keyframes preserved
258+
expect(result).toContain('"0%": { opacity: 0 }');
259+
expect(result).toContain('"50%": { opacity: 0.7 }');
260+
// 25% appears before 50% in the string
261+
expect(result.indexOf('"25%"')).toBeLessThan(result.indexOf('"50%"'));
262+
});
263+
264+
it("addKeyframeToScript replaces value when percentage already exists", () => {
265+
const result = addKeyframeToScript(SCRIPT_D, "#box-to-200-visual", 50, { opacity: 0.99 });
266+
expect(result).toContain("opacity: 0.99");
267+
expect(result).not.toContain("opacity: 0.7");
268+
// Only one "50%" in the result
269+
expect((result.match(/"50%"/g) ?? []).length).toBe(1);
270+
});
271+
272+
it("removeKeyframeFromScript removes the target percentage", () => {
273+
// Remove 50% from 0%/50%/100% → leaves 0%/100% (no collapse in T6c)
274+
const result = removeKeyframeFromScript(SCRIPT_D, "#box-to-200-visual", 50);
275+
expect(result).not.toContain('"50%"');
276+
expect(result).toContain('"0%"');
277+
expect(result).toContain('"100%"');
278+
});
279+
280+
it("updateKeyframeInScript on unknown id returns script unchanged", () => {
281+
const result = updateKeyframeInScript(SCRIPT_D, "bad-id", 50, { opacity: 0.5 });
282+
expect(result).toBe(SCRIPT_D);
283+
});
284+
});

0 commit comments

Comments
 (0)