diff --git a/bun.lock b/bun.lock index f2e094d40..2ee4d14bf 100644 --- a/bun.lock +++ b/bun.lock @@ -22,7 +22,7 @@ }, "packages/aws-lambda": { "name": "@hyperframes/aws-lambda", - "version": "0.6.91", + "version": "0.6.93", "dependencies": { "@aws-sdk/client-s3": "^3.700.0", "@aws-sdk/client-sfn": "^3.700.0", @@ -54,7 +54,7 @@ }, "packages/cli": { "name": "@hyperframes/cli", - "version": "0.6.91", + "version": "0.6.93", "bin": { "hyperframes": "./dist/cli.js", }, @@ -101,12 +101,13 @@ }, "packages/core": { "name": "@hyperframes/core", - "version": "0.6.91", + "version": "0.6.93", "dependencies": { "@babel/parser": "^7.27.0", "@chenglou/pretext": "^0.0.5", "acorn": "^8.17.0", "acorn-walk": "^8.3.5", + "magic-string": "^0.30.21", "postcss": "^8.5.8", "postcss-selector-parser": "^7.1.2", "recast": "^0.23.11", @@ -133,7 +134,7 @@ }, "packages/engine": { "name": "@hyperframes/engine", - "version": "0.6.91", + "version": "0.6.93", "dependencies": { "@hono/node-server": "^1.13.0", "@hyperframes/core": "workspace:^", @@ -151,7 +152,7 @@ }, "packages/gcp-cloud-run": { "name": "@hyperframes/gcp-cloud-run", - "version": "0.6.91", + "version": "0.6.93", "dependencies": { "@google-cloud/storage": "^7.14.0", "@google-cloud/workflows": "^4.2.0", @@ -171,7 +172,7 @@ }, "packages/player": { "name": "@hyperframes/player", - "version": "0.6.91", + "version": "0.6.93", "devDependencies": { "@types/bun": "^1.1.0", "gsap": "^3.12.5", @@ -183,7 +184,7 @@ }, "packages/producer": { "name": "@hyperframes/producer", - "version": "0.6.91", + "version": "0.6.93", "dependencies": { "@fontsource/archivo-black": "^5.2.8", "@fontsource/eb-garamond": "^5.2.7", @@ -237,7 +238,7 @@ }, "packages/shader-transitions": { "name": "@hyperframes/shader-transitions", - "version": "0.6.91", + "version": "0.6.93", "dependencies": { "html2canvas": "^1.4.1", }, @@ -249,7 +250,7 @@ }, "packages/studio": { "name": "@hyperframes/studio", - "version": "0.6.91", + "version": "0.6.93", "dependencies": { "@codemirror/autocomplete": "^6.20.1", "@codemirror/commands": "^6.10.3", diff --git a/packages/core/package.json b/packages/core/package.json index a9ae4f436..25c59bd26 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -202,6 +202,7 @@ "@chenglou/pretext": "^0.0.5", "acorn": "^8.17.0", "acorn-walk": "^8.3.5", + "magic-string": "^0.30.21", "postcss": "^8.5.8", "postcss-selector-parser": "^7.1.2", "recast": "^0.23.11" diff --git a/packages/core/src/parsers/gsapParserAcorn.ts b/packages/core/src/parsers/gsapParserAcorn.ts index 36026c142..954d6b6e4 100644 --- a/packages/core/src/parsers/gsapParserAcorn.ts +++ b/packages/core/src/parsers/gsapParserAcorn.ts @@ -419,7 +419,7 @@ const EXTRAS_KEYS = new Set([ "immediateRender", ]); -interface TweenCallInfo { +export interface TweenCallInfo { node: any; /** acorn-walk ancestor array at the call site (root→call, call is last). */ ancestors: any[]; @@ -1041,6 +1041,46 @@ function assignStableIds(anims: Omit[]): GsapAnimation[] { }); } +// ── Write-path internal parse ───────────────────────────────────────────────── + +export interface ParsedGsapAcornForWrite { + ast: any; + timelineVar: string; + located: Array<{ id: string; call: TweenCallInfo; animation: GsapAnimation }>; +} + +/** + * Parse a GSAP script and return internal AST + call nodes for the write path. + * Consumed by gsapWriterAcorn.ts (magic-string offset-splice). + */ +export function parseGsapScriptAcornForWrite(script: string): ParsedGsapAcornForWrite | null { + try { + const ast = acorn.parse(script, { + ecmaVersion: "latest", + sourceType: "script", + locations: true, + }); + 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); + sortBySourcePosition(calls); + const rawAnims = calls.map((call) => tweenCallToAnimation(call, scope, script)); + applyTimelineDefaults(rawAnims, detection.defaults); + resolveTimelinePositions(rawAnims); + const animations = assignStableIds(rawAnims); + const located = calls.map((call, i) => ({ + id: animations[i]!.id, + call, + animation: animations[i]!, + })); + return { ast, timelineVar, located }; + } catch { + return null; + } +} + // ── Public API ──────────────────────────────────────────────────────────────── /** diff --git a/packages/core/src/parsers/gsapWriter.acorn.test.ts b/packages/core/src/parsers/gsapWriter.acorn.test.ts new file mode 100644 index 000000000..27f818642 --- /dev/null +++ b/packages/core/src/parsers/gsapWriter.acorn.test.ts @@ -0,0 +1,284 @@ +// fallow-ignore-file duplication +/** + * T6c — acorn write path with magic-string offset-splice. + * + * Verifies that each write op touches only the intended byte span and leaves + * every other character identical to the original source. + */ +import { describe, expect, it } from "vitest"; +import { + addAnimationToScript, + addKeyframeToScript, + removeAnimationFromScript, + removeKeyframeFromScript, + updateAnimationInScript, + updateKeyframeInScript, +} from "./gsapWriterAcorn.js"; + +// --------------------------------------------------------------------------- +// Fixture scripts +// --------------------------------------------------------------------------- + +const SCRIPT_A = `\ +var tl = gsap.timeline({ paused: true }); +tl.to("#hero", { opacity: 1, duration: 0.5, ease: "power3.out" }, 0.2); +window.__timelines["t"] = tl;`; + +const SCRIPT_B = `\ +var tl = gsap.timeline({ paused: true }); +tl.to("#hero", { opacity: 1, duration: 0.5, ease: "power3.out" }, 0); +tl.to("#hero", { opacity: 0, duration: 0.3, ease: "power3.in" }, 1); +window.__timelines["t"] = tl;`; + +const SCRIPT_C = `\ +var tl = gsap.timeline({ paused: true }); +tl.from(".a", { opacity: 0, duration: 0.5 }, 0) + .from(".b", { opacity: 0, duration: 0.3 }, 0.5); +window.__timelines["t"] = tl;`; + +// 3-keyframe script so removal leaves ≥2 kfs (no collapse needed) +const SCRIPT_D = `\ +var tl = gsap.timeline({ paused: true }); +tl.to("#box", { keyframes: { "0%": { opacity: 0 }, "50%": { opacity: 0.7 }, "100%": { opacity: 1 } }, duration: 0.5 }, 0.2); +window.__timelines["t"] = tl;`; + +// --------------------------------------------------------------------------- +// No-op identity +// --------------------------------------------------------------------------- + +describe("T6c — no-op identity", () => { + it("updateAnimationInScript with empty updates returns identical script", () => { + const result = updateAnimationInScript(SCRIPT_A, "#hero-to-200-visual", {}); + expect(result).toBe(SCRIPT_A); + }); + + it("updateAnimationInScript with unknown ID returns identical script", () => { + const result = updateAnimationInScript(SCRIPT_A, "not-a-real-id", { ease: "power2.in" }); + expect(result).toBe(SCRIPT_A); + }); +}); + +// --------------------------------------------------------------------------- +// updateAnimationInScript +// --------------------------------------------------------------------------- + +describe("T6c — updateAnimationInScript", () => { + it("updates ease value in-place", () => { + const result = updateAnimationInScript(SCRIPT_A, "#hero-to-200-visual", { + ease: "power2.in", + }); + expect(result).toContain('"power2.in"'); + expect(result).not.toContain('"power3.out"'); + // Preamble + postamble unchanged + expect(result).toContain("var tl = gsap.timeline({ paused: true });"); + expect(result).toContain('window.__timelines["t"] = tl;'); + }); + + it("updates duration value in-place", () => { + const result = updateAnimationInScript(SCRIPT_A, "#hero-to-200-visual", { + duration: 1.2, + }); + expect(result).toContain("duration: 1.2"); + expect(result).not.toContain("duration: 0.5"); + expect(result).toContain('"power3.out"'); + }); + + it("updates position arg in-place", () => { + const result = updateAnimationInScript(SCRIPT_A, "#hero-to-200-visual", { + position: 0.5, + }); + expect(result).toContain("}, 0.5)"); + expect(result).not.toContain("}, 0.2)"); + expect(result).toContain("opacity: 1"); + }); + + it("inserts ease when property was absent", () => { + const noEase = `\ +var tl = gsap.timeline({ paused: true }); +tl.to("#hero", { opacity: 1, duration: 0.5 }, 0.2); +window.__timelines["t"] = tl;`; + const result = updateAnimationInScript(noEase, "#hero-to-200-visual", { + ease: "power3.out", + }); + expect(result).toContain('ease: "power3.out"'); + // Duration, opacity, position unchanged + expect(result).toContain("duration: 0.5"); + expect(result).toContain("opacity: 1"); + expect(result).toContain("}, 0.2)"); + }); + + it("updates fromTo — ease on toVars", () => { + const fromTo = `\ +var tl = gsap.timeline({ paused: true }); +tl.fromTo("#hero", { opacity: 0 }, { opacity: 1, duration: 0.5, ease: "power3.out" }, 0.1); +window.__timelines["t"] = tl;`; + // ID: target="#hero", method="fromTo", pos=0.1 → posKey=100, propertyGroup=visual + const result = updateAnimationInScript(fromTo, "#hero-fromTo-100-visual", { + ease: "back.out", + }); + expect(result).toContain('"back.out"'); + expect(result).not.toContain('"power3.out"'); + expect(result).toContain("opacity: 0"); + }); + + it("byte-identity outside edited ease span", () => { + const result = updateAnimationInScript(SCRIPT_A, "#hero-to-200-visual", { + ease: "power2.in", + }); + const oldEaseStart = SCRIPT_A.indexOf('"power3.out"'); + const newEaseStart = result.indexOf('"power2.in"'); + // Everything before the ease value is identical + expect(result.slice(0, newEaseStart)).toBe(SCRIPT_A.slice(0, oldEaseStart)); + // Everything after the ease value close-quote is identical + const oldAfter = SCRIPT_A.slice(oldEaseStart + '"power3.out"'.length); + const newAfter = result.slice(newEaseStart + '"power2.in"'.length); + expect(newAfter).toBe(oldAfter); + }); +}); + +// --------------------------------------------------------------------------- +// removeAnimationFromScript +// --------------------------------------------------------------------------- + +describe("T6c — removeAnimationFromScript", () => { + it("removes a standalone tween statement", () => { + const result = removeAnimationFromScript(SCRIPT_B, "#hero-to-0-visual"); + expect(result).not.toContain("power3.out"); + expect(result).toContain("power3.in"); + expect(result).toContain('window.__timelines["t"] = tl;'); + }); + + it("removes last chain link (outer call)", () => { + // SCRIPT_C: tl.from(".a",...,0).from(".b",...,0.5) + // Remove .b (outermost call = last in source) + const result = removeAnimationFromScript(SCRIPT_C, ".b-from-500-visual"); + expect(result).toContain('.from(".a"'); + expect(result).not.toContain('.from(".b"'); + // The statement should still end with ; (no dangling chain) + expect(result).toContain("}, 0);"); + }); + + it("removes inner chain link", () => { + // SCRIPT_C: tl.from(".a",...,0).from(".b",...,0.5) + // Remove .a (innermost call = first in source) + const result = removeAnimationFromScript(SCRIPT_C, ".a-from-0-visual"); + expect(result).not.toContain('.from(".a"'); + expect(result).toContain('.from(".b"'); + // Chain is still rooted at tl (whitespace between tl and .from is valid JS) + expect(result).toMatch(/tl[\s.]*from\("\.b"/); + }); + + it("unknown ID returns script unchanged", () => { + const result = removeAnimationFromScript(SCRIPT_A, "nonexistent-id"); + expect(result).toBe(SCRIPT_A); + }); +}); + +// --------------------------------------------------------------------------- +// addAnimationToScript +// --------------------------------------------------------------------------- + +describe("T6c — addAnimationToScript", () => { + it("inserts new tween after last existing tween", () => { + const { script: result } = addAnimationToScript(SCRIPT_A, { + targetSelector: "#new", + method: "to", + position: 0.5, + duration: 0.3, + properties: { x: 100 }, + }); + expect(result).toContain('tl.to("#new"'); + expect(result).toContain("x: 100"); + expect(result).toContain("duration: 0.3"); + // Original content preserved + expect(result).toContain('tl.to("#hero"'); + expect(result).toContain('window.__timelines["t"] = tl;'); + // New tween comes after hero tween + expect(result.indexOf('tl.to("#new"')).toBeGreaterThan(result.indexOf('tl.to("#hero"')); + }); + + it("returns a non-empty stable id for the new animation", () => { + const { id } = addAnimationToScript(SCRIPT_A, { + targetSelector: "#new", + method: "to", + position: 0.5, + duration: 0.3, + properties: { x: 100 }, + }); + expect(id).toBeTruthy(); + expect(typeof id).toBe("string"); + }); + + it("inserts after timeline declaration when script has no tweens", () => { + const empty = `var tl = gsap.timeline({ paused: true });\nwindow.__timelines["t"] = tl;`; + const { script: result } = addAnimationToScript(empty, { + targetSelector: "#hero", + method: "to", + position: 0, + duration: 0.5, + properties: { opacity: 1 }, + }); + expect(result).toContain('tl.to("#hero"'); + // Inserted after timeline declaration + expect(result.indexOf('tl.to("#hero"')).toBeGreaterThan(result.indexOf("gsap.timeline")); + }); +}); + +// --------------------------------------------------------------------------- +// Keyframe write ops +// --------------------------------------------------------------------------- + +describe("T6c — keyframe write ops", () => { + it("updateKeyframeInScript replaces keyframe value at given percentage", () => { + // Update 50% from { opacity: 0.7 } to { opacity: 0.5 } + const result = updateKeyframeInScript(SCRIPT_D, "#box-to-200-visual", 50, { opacity: 0.5 }); + expect(result).toContain("opacity: 0.5"); + expect(result).not.toContain("opacity: 0.7"); + // Other keyframes unchanged + expect(result).toContain('"0%": { opacity: 0 }'); + expect(result).toContain('"100%": { opacity: 1 }'); + }); + + it("updateKeyframeInScript preserves bytes outside the edited value", () => { + const result = updateKeyframeInScript(SCRIPT_D, "#box-to-200-visual", 100, { + opacity: 0.9, + }); + // The 50% keyframe is untouched + expect(result).toContain('"50%": { opacity: 0.7 }'); + // Duration and position are unchanged + expect(result).toContain("duration: 0.5"); + expect(result).toContain("}, 0.2)"); + }); + + it("addKeyframeToScript inserts new percentage in sorted order", () => { + const result = addKeyframeToScript(SCRIPT_D, "#box-to-200-visual", 25, { opacity: 0.3 }); + expect(result).toContain('"25%"'); + expect(result).toContain("opacity: 0.3"); + // Original keyframes preserved + expect(result).toContain('"0%": { opacity: 0 }'); + expect(result).toContain('"50%": { opacity: 0.7 }'); + // 25% appears before 50% in the string + expect(result.indexOf('"25%"')).toBeLessThan(result.indexOf('"50%"')); + }); + + it("addKeyframeToScript replaces value when percentage already exists", () => { + const result = addKeyframeToScript(SCRIPT_D, "#box-to-200-visual", 50, { opacity: 0.99 }); + expect(result).toContain("opacity: 0.99"); + expect(result).not.toContain("opacity: 0.7"); + // Only one "50%" in the result + expect((result.match(/"50%"/g) ?? []).length).toBe(1); + }); + + it("removeKeyframeFromScript removes the target percentage", () => { + // Remove 50% from 0%/50%/100% → leaves 0%/100% (no collapse in T6c) + const result = removeKeyframeFromScript(SCRIPT_D, "#box-to-200-visual", 50); + expect(result).not.toContain('"50%"'); + expect(result).toContain('"0%"'); + expect(result).toContain('"100%"'); + }); + + it("updateKeyframeInScript on unknown id returns script unchanged", () => { + const result = updateKeyframeInScript(SCRIPT_D, "bad-id", 50, { opacity: 0.5 }); + expect(result).toBe(SCRIPT_D); + }); +}); diff --git a/packages/core/src/parsers/gsapWriterAcorn.ts b/packages/core/src/parsers/gsapWriterAcorn.ts new file mode 100644 index 000000000..58b57f002 --- /dev/null +++ b/packages/core/src/parsers/gsapWriterAcorn.ts @@ -0,0 +1,368 @@ +// fallow-ignore-file duplication +/** + * Browser-safe GSAP write path — magic-string offset-splice. + * + * T6c: edits GSAP scripts by overwriting/removing byte ranges in the original + * source. Every byte outside the edited span is preserved verbatim — no + * pretty-printer churn. Consumes ParsedGsapAcornForWrite from gsapParserAcorn.ts. + */ +import MagicString from "magic-string"; +import type { GsapAnimation } from "./gsapSerialize.js"; +import { parseGsapScriptAcornForWrite, type TweenCallInfo } from "./gsapParserAcorn.js"; +import * as acornWalk from "acorn-walk"; + +// ── Code generation helpers ────────────────────────────────────────────────── + +function valueToCode(value: number | string): string { + if (typeof value === "string" && value.startsWith("__raw:")) return value.slice(6); + if (typeof value === "string") return JSON.stringify(value); + return String(value); +} + +function safeKey(key: string): string { + return /^[A-Za-z_$][\w$]*$/.test(key) ? key : JSON.stringify(key); +} + +// fallow-ignore-next-line complexity +function buildTweenStatementCode(timelineVar: string, anim: Omit): string { + const selector = JSON.stringify(anim.targetSelector); + const props: Record = { ...anim.properties }; + if (anim.method !== "set" && anim.duration !== undefined) props.duration = anim.duration; + if (anim.ease) props.ease = anim.ease; + const entries = Object.entries(props).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`); + if (anim.extras) { + for (const [k, v] of Object.entries(anim.extras)) { + entries.push(`${safeKey(k)}: ${valueToCode(v as number | string)}`); + } + } + const objCode = `{ ${entries.join(", ")} }`; + const posCode = valueToCode( + typeof anim.position === "number" ? anim.position : (anim.position ?? 0), + ); + if (anim.method === "fromTo") { + const fromEntries = Object.entries(anim.fromProperties ?? {}).map( + ([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`, + ); + return `${timelineVar}.fromTo(${selector}, { ${fromEntries.join(", ")} }, ${objCode}, ${posCode});`; + } + return `${timelineVar}.${anim.method}(${selector}, ${objCode}, ${posCode});`; +} + +// ── AST node helpers ───────────────────────────────────────────────────────── + +function isObjectProperty(prop: any): boolean { + return prop?.type === "ObjectProperty" || prop?.type === "Property"; +} + +function propKeyName(prop: any): string | undefined { + return prop?.key?.name ?? prop?.key?.value; +} + +function findPropertyNode(varsArgNode: any, key: string): any | undefined { + if (varsArgNode?.type !== "ObjectExpression") return undefined; + for (const prop of varsArgNode.properties ?? []) { + if (!isObjectProperty(prop)) continue; + if (propKeyName(prop) === key) return prop; + } + return undefined; +} + +function findEnclosingExpressionStatement(ancestors: any[]): any | null { + for (let i = ancestors.length - 2; i >= 0; i--) { + if (ancestors[i]?.type === "ExpressionStatement") return ancestors[i]; + } + return null; +} + +/** Find the VariableDeclaration statement for `tl = gsap.timeline(...)`. */ +function findTimelineDeclarationStatement(ast: any, timelineVar: string): any | null { + let found: any = null; + acornWalk.simple(ast, { + // fallow-ignore-next-line complexity + VariableDeclaration(node: any) { + if (found) return; + for (const decl of node.declarations ?? []) { + if ( + decl.id?.name === timelineVar && + decl.init?.type === "CallExpression" && + decl.init.callee?.type === "MemberExpression" && + decl.init.callee.object?.name === "gsap" && + decl.init.callee.property?.name === "timeline" + ) { + found = node; + } + } + }, + }); + return found; +} + +// ── Property splice helpers ─────────────────────────────────────────────────── + +/** + * Remove a property from a properties array, handling its comma. + * `editableProps` must be the isObjectProperty-filtered subset in source order. + */ +function removeProp(ms: MagicString, propNode: any, editableProps: any[]): void { + const idx = editableProps.indexOf(propNode); + if (idx === -1) return; + if (editableProps.length === 1) { + ms.remove(propNode.start, propNode.end); + } else if (idx === 0) { + // First prop: remove from its start to next prop start (drops trailing ", ") + ms.remove(editableProps[0].start, editableProps[1].start); + } else { + // Non-first: remove from prev prop end to this prop end (drops leading ", ") + ms.remove(editableProps[idx - 1].end, propNode.end); + } +} + +/** + * Update a property value if it exists, or append a new key: val before the + * closing `}`. Call with the full ObjectExpression node. + */ +function upsertProp(ms: MagicString, objNode: any, key: string, value: number | string): void { + if (objNode?.type !== "ObjectExpression") return; + const existing = findPropertyNode(objNode, key); + if (existing) { + ms.overwrite(existing.value.start, existing.value.end, valueToCode(value)); + } else { + const sep = objNode.properties.length > 0 ? ", " : ""; + ms.appendLeft(objNode.end - 1, `${sep}${safeKey(key)}: ${valueToCode(value)}`); + } +} + +// ── Public write API ───────────────────────────────────────────────────────── + +// fallow-ignore-next-line complexity +export function updateAnimationInScript( + script: string, + animationId: string, + updates: Partial, +): string { + if (!Object.keys(updates).length) return script; + const parsed = parseGsapScriptAcornForWrite(script); + if (!parsed) return script; + const target = parsed.located.find((l) => l.id === animationId); + if (!target) return script; + + const ms = new MagicString(script); + const { call }: { call: TweenCallInfo } = target; + + if (updates.duration !== undefined) { + upsertProp(ms, call.varsArg, "duration", updates.duration); + } + + if (updates.ease !== undefined) { + upsertProp(ms, call.varsArg, "ease", updates.ease); + } + + if (updates.properties) { + for (const [key, value] of Object.entries(updates.properties)) { + upsertProp(ms, call.varsArg, key, value); + } + } + + if (updates.fromProperties && call.method === "fromTo" && call.fromArg) { + for (const [key, value] of Object.entries(updates.fromProperties)) { + upsertProp(ms, call.fromArg, key, value); + } + } + + if (updates.position !== undefined) { + const posIdx = call.method === "fromTo" ? 3 : 2; + const posArgNode = call.node.arguments?.[posIdx]; + if (posArgNode) { + ms.overwrite(posArgNode.start, posArgNode.end, valueToCode(updates.position)); + } else { + ms.appendLeft(call.node.end - 1, `, ${valueToCode(updates.position)}`); + } + } + + return ms.toString(); +} + +export function addAnimationToScript( + script: string, + animation: Omit, +): { script: string; id: string } { + const parsed = parseGsapScriptAcornForWrite(script); + if (!parsed) return { script, id: "" }; + + const ms = new MagicString(script); + const statementCode = buildTweenStatementCode(parsed.timelineVar, animation); + + let insertionPoint: number; + if (parsed.located.length > 0) { + const lastCall = parsed.located[parsed.located.length - 1]!.call; + const exprStmt = findEnclosingExpressionStatement(lastCall.ancestors); + insertionPoint = exprStmt?.end ?? lastCall.node.end; + } else { + const tlDecl = findTimelineDeclarationStatement(parsed.ast, parsed.timelineVar); + insertionPoint = tlDecl?.end ?? script.length; + } + + ms.appendLeft(insertionPoint, "\n" + statementCode); + + const result = ms.toString(); + const reParsed = parseGsapScriptAcornForWrite(result); + const newId = reParsed?.located[reParsed.located.length - 1]?.id ?? ""; + return { script: result, id: newId }; +} + +export function removeAnimationFromScript(script: string, animationId: string): string { + const parsed = parseGsapScriptAcornForWrite(script); + if (!parsed) return script; + let target = parsed.located.find((l) => l.id === animationId); + if (!target) { + const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-"); + target = parsed.located.find((l) => l.id === convertedId); + } + if (!target) return script; + + const ms = new MagicString(script); + const N = target.call.node; + const exprStmt = findEnclosingExpressionStatement(target.call.ancestors); + + if (N.callee?.object?.type !== "CallExpression" && exprStmt?.expression === N) { + // Standalone `tl.method(...)` — remove the whole ExpressionStatement + const end = + exprStmt.end < script.length && script[exprStmt.end] === "\n" + ? exprStmt.end + 1 + : exprStmt.end; + ms.remove(exprStmt.start, end); + } else { + // Chain link — splice out `.method(args)` from N.callee.object.end to N.end + ms.remove(N.callee.object.end, N.end); + } + + return ms.toString(); +} + +// ── Keyframe write ops ──────────────────────────────────────────────────────── + +const PERCENTAGE_KEY_RE = /^(\d+(?:\.\d+)?)%$/; + +function percentageFromKey(key: string): number { + const m = PERCENTAGE_KEY_RE.exec(key); + return m ? Number.parseFloat(m[1] ?? "0") : Number.NaN; +} + +function buildKeyframeValueCode( + properties: Record, + ease?: string, +): string { + const entries = Object.entries(properties).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`); + if (ease) entries.push(`ease: ${JSON.stringify(ease)}`); + return `{ ${entries.join(", ")} }`; +} + +function findKfPropByPct(kfNode: any, percentage: number): { prop: any; idx: number } | null { + const props = kfNode.properties ?? []; + for (let i = 0; i < props.length; i++) { + const prop = props[i]; + if (!isObjectProperty(prop)) continue; + const key = propKeyName(prop); + if (typeof key === "string" && Math.abs(percentageFromKey(key) - percentage) < 0.001) { + return { prop, idx: i }; + } + } + return null; +} + +export function updateKeyframeInScript( + script: string, + animationId: string, + percentage: number, + properties: Record, + ease?: string, +): string { + const parsed = parseGsapScriptAcornForWrite(script); + if (!parsed) return script; + const target = parsed.located.find((l) => l.id === animationId); + if (!target) return script; + + const kfPropNode = findPropertyNode(target.call.varsArg, "keyframes"); + if (!kfPropNode || kfPropNode.value?.type !== "ObjectExpression") return script; + + const match = findKfPropByPct(kfPropNode.value, percentage); + if (!match) return script; + + const ms = new MagicString(script); + ms.overwrite( + match.prop.value.start, + match.prop.value.end, + buildKeyframeValueCode(properties, ease), + ); + return ms.toString(); +} + +// fallow-ignore-next-line complexity +export function addKeyframeToScript( + script: string, + animationId: string, + percentage: number, + properties: Record, + ease?: string, +): string { + const parsed = parseGsapScriptAcornForWrite(script); + if (!parsed) return script; + const target = parsed.located.find((l) => l.id === animationId); + if (!target) return script; + + const kfPropNode = findPropertyNode(target.call.varsArg, "keyframes"); + if (!kfPropNode || kfPropNode.value?.type !== "ObjectExpression") return script; + const kfNode = kfPropNode.value; + + const ms = new MagicString(script); + const pctKey = `${percentage}%`; + const valueCode = buildKeyframeValueCode(properties, ease); + + const existing = findKfPropByPct(kfNode, percentage); + if (existing) { + ms.overwrite(existing.prop.value.start, existing.prop.value.end, valueCode); + } else { + const allProps = (kfNode.properties ?? []).filter((p: any) => isObjectProperty(p)); + let insertBeforeProp: any = null; + for (const prop of allProps) { + const key = propKeyName(prop); + if (typeof key === "string" && percentageFromKey(key) > percentage) { + insertBeforeProp = prop; + break; + } + } + if (insertBeforeProp) { + // Insert `"pct%": {...}, ` before the next higher-percentage prop + ms.appendLeft(insertBeforeProp.start, `${JSON.stringify(pctKey)}: ${valueCode}, `); + } else { + // Append at end of kfNode properties + const sep = allProps.length > 0 ? ", " : ""; + ms.appendLeft(kfNode.end - 1, `${sep}${JSON.stringify(pctKey)}: ${valueCode}`); + } + } + + return ms.toString(); +} + +export function removeKeyframeFromScript( + script: string, + animationId: string, + percentage: number, +): string { + const parsed = parseGsapScriptAcornForWrite(script); + if (!parsed) return script; + const target = parsed.located.find((l) => l.id === animationId); + if (!target) return script; + + const kfPropNode = findPropertyNode(target.call.varsArg, "keyframes"); + if (!kfPropNode || kfPropNode.value?.type !== "ObjectExpression") return script; + const kfNode = kfPropNode.value; + + const match = findKfPropByPct(kfNode, percentage); + if (!match) return script; + + const allProps = (kfNode.properties ?? []).filter((p: any) => isObjectProperty(p)); + const ms = new MagicString(script); + removeProp(ms, match.prop, allProps); + return ms.toString(); +}