Files
hyperframes/packages/parsers/src/gsapParserAcorn.ts
T

1833 lines
67 KiB
TypeScript

// fallow-ignore-file code-duplication
/**
* Browser-safe GSAP read path — acorn + acorn-walk.
*
* T6b oracle: produces identical ParsedGsap output to gsapParser.ts (recast).
* Replaces recast as the shared implementation once T6d passes.
*
* Write path (T6c) will add magic-string splice once read parity is confirmed.
* No Node globals, no fs, no require — safe to bundle for browser use.
*/
import * as acorn from "acorn";
import * as acornWalk from "acorn-walk";
import type {
ArcPathConfig,
GsapAnimation,
GsapKeyframesData,
GsapMethod,
GsapPercentageKeyframe,
ParsedGsap,
} from "./gsapSerialize.js";
import { classifyTweenPropertyGroup } from "./gsapConstants.js";
import { buildArcPath } from "./gsapSerialize.js";
import { inlineComputedTimelines, readProvenance } from "./gsapInline.js";
// Browser-safe re-exports so studio code can build arc config without importing
// the recast parser (this acorn module is the browser-safe gsap subpath).
export { buildArcPath, editabilityForProvenance } from "./gsapSerialize.js";
export type {
ArcPathConfig,
ArcPathSegment,
MotionPathShape,
GsapProvenance,
GsapProvenanceKind,
KeyframeEditability,
} from "./gsapSerialize.js";
const GSAP_METHODS = new Set<string>(["set", "to", "from", "fromTo"]);
const QUERY_METHODS = new Set(["querySelector", "querySelectorAll"]);
const ITERATION_METHODS = new Set(["forEach", "map"]);
const SCOPE_NODE_TYPES = new Set([
"Program",
"FunctionDeclaration",
"FunctionExpression",
"ArrowFunctionExpression",
]);
// ── Types ────────────────────────────────────────────────────────────────────
type ScopeBindings = ReadonlyMap<string, number | string | boolean>;
/** Per-scope element bindings: scopeNode → (variable name → selector). */
type TargetBindings = Map<any, Map<string, string>>;
/**
* Side-table of top-level const/let ARRAY and OBJECT literals (of literals),
* captured by `collectScopeBindings` and stashed on the scope Map so that
* `resolveNode` can fold member access (`H.bar0`) and computed array index
* (`SPARK[0][1]`) without changing the resolveNode signature at its ~15 call
* sites. ponytail: hidden prop on the Map beats threading a new param everywhere.
*/
const CONST_NODES = Symbol("hf.constNodes");
type ConstNodes = Map<string, any>;
function constNodesOf(
scope: ReadonlyMap<string, number | string | boolean>,
): ConstNodes | undefined {
return (scope as any)[CONST_NODES];
}
/** Whitelisted Math members we fold over constant args (deterministic, no I/O). */
const MATH_FNS = new Set(["min", "max", "round", "floor", "ceil", "abs", "sqrt", "sign", "trunc"]);
const MATH_CONSTS: Record<string, number> = { PI: Math.PI, E: Math.E, SQRT2: Math.SQRT2 };
/**
* Fold a MemberExpression (`obj.prop`, `arr[i]`, `Math.PI`, `Math.round(x)`)
* against the const-node side-table. Returns undefined when not statically
* resolvable (genuinely runtime-dynamic) so the caller falls back to __raw.
*/
// fallow-ignore-next-line complexity
function resolveMemberNode(
node: any,
scope: ReadonlyMap<string, number | string | boolean>,
): number | string | boolean | undefined {
// Math.PI / Math.E
if (node.object?.type === "Identifier" && node.object.name === "Math") {
const key = node.property?.name;
return typeof key === "string" ? MATH_CONSTS[key] : undefined;
}
// Resolve the object to a const AST node (array/object literal), descending
// through chained member/index access (SPARK[0][1], H.bar0).
const objNode = resolveConstNode(node.object, scope);
if (!objNode) return undefined;
let valueNode: any;
if (node.computed) {
const idx = resolveNode(node.property, scope);
if (objNode.type === "ArrayExpression" && typeof idx === "number") {
valueNode = objNode.elements?.[idx];
} else if (
objNode.type === "ObjectExpression" &&
(typeof idx === "string" || typeof idx === "number")
) {
valueNode = findPropertyNode(objNode, String(idx));
}
} else if (objNode.type === "ObjectExpression") {
valueNode = findPropertyNode(objNode, node.property?.name ?? node.property?.value);
}
return valueNode ? resolveNode(valueNode, scope) : undefined;
}
/**
* Descend one MemberExpression step into an already-resolved const ARRAY/OBJECT
* node (`objNode[idx]` / `objNode.prop`). Split out of resolveConstNode so the
* recursive walk stays flat/low-branch.
*/
function resolveConstMember(
objNode: any,
node: any,
scope: ReadonlyMap<string, number | string | boolean>,
): any {
if (!node.computed) {
return objNode.type === "ObjectExpression"
? findPropertyNode(objNode, node.property?.name ?? node.property?.value)
: undefined;
}
const idx = resolveNode(node.property, scope);
if (objNode.type === "ArrayExpression" && typeof idx === "number") return objNode.elements?.[idx];
if (objNode.type === "ObjectExpression") return findPropertyNode(objNode, String(idx));
return undefined;
}
/** Resolve an expression to a const ARRAY/OBJECT AST node (for nested access). */
function resolveConstNode(node: any, scope: ReadonlyMap<string, number | string | boolean>): any {
if (!node) return undefined;
if (node.type === "ArrayExpression" || node.type === "ObjectExpression") return node;
if (node.type === "Identifier") return constNodesOf(scope)?.get(node.name);
if (node.type !== "MemberExpression") return undefined;
const objNode = resolveConstNode(node.object, scope);
return objNode ? resolveConstMember(objNode, node, scope) : undefined;
}
// ── Value resolution ─────────────────────────────────────────────────────────
// fallow-ignore-next-line complexity
function resolveNode(
node: any,
scope: ReadonlyMap<string, number | string | boolean>,
): number | string | boolean | undefined {
if (!node) return undefined;
if (node.type === "NumericLiteral" || (node.type === "Literal" && typeof node.value === "number"))
return node.value;
if (node.type === "StringLiteral" || (node.type === "Literal" && typeof node.value === "string"))
return node.value;
if (
node.type === "BooleanLiteral" ||
(node.type === "Literal" && typeof node.value === "boolean")
)
return node.value;
if (node.type === "UnaryExpression" && node.operator === "-" && node.argument) {
const val = resolveNode(node.argument, scope);
return typeof val === "number" ? -val : undefined;
}
if (node.type === "BinaryExpression") {
const left = resolveNode(node.left, scope);
const right = resolveNode(node.right, scope);
if (typeof left === "number" && typeof right === "number") {
switch (node.operator) {
case "+":
return left + right;
case "-":
return left - right;
case "*":
return left * right;
case "/":
return right !== 0 ? left / right : undefined;
}
}
if (typeof left === "string" && node.operator === "+") return left + String(right ?? "");
if (typeof right === "string" && node.operator === "+") return String(left ?? "") + right;
}
if (node.type === "Identifier" && scope.has(node.name)) {
return scope.get(node.name);
}
if (node.type === "TemplateLiteral" && node.expressions?.length === 0) {
return node.quasis?.[0]?.value?.cooked ?? undefined;
}
if (node.type === "MemberExpression") {
return resolveMemberNode(node, scope);
}
// Whitelisted Math.fn(...) over constant args (Math.round/min/max/...).
if (
node.type === "CallExpression" &&
node.callee?.type === "MemberExpression" &&
node.callee.object?.type === "Identifier" &&
node.callee.object.name === "Math" &&
MATH_FNS.has(node.callee.property?.name)
) {
const args = (node.arguments ?? []).map((a: any) => resolveNode(a, scope));
if (args.every((a: unknown) => typeof a === "number")) {
return (Math as any)[node.callee.property.name](...(args as number[]));
}
}
return undefined;
}
function extractLiteralValue(node: any, scope: ScopeBindings): unknown {
return resolveNode(node, scope);
}
// ── DOM selector resolution ───────────────────────────────────────────────────
// fallow-ignore-next-line complexity
function selectorFromQueryCall(node: any, scope: ScopeBindings): string | null {
if (node?.type !== "CallExpression") return null;
const callee = node.callee;
if (callee?.type !== "MemberExpression" || callee.property?.type !== "Identifier") return null;
const method = callee.property.name;
const argValue = resolveNode(node.arguments?.[0], scope);
if (typeof argValue !== "string" || argValue.length === 0) return null;
if (QUERY_METHODS.has(method) || method === "toArray") return argValue;
if (method === "getElementById") return `#${argValue}`;
return null;
}
// ── Ancestor-based scope helpers (replaces NodePath walking) ──────────────────
/**
* Return the nearest ancestor node whose type is in SCOPE_NODE_TYPES.
* `ancestors` is the acorn-walk ancestor array (root→current, current is last).
*/
function enclosingScopeNodeFromAncestors(ancestors: any[]): any {
for (let i = ancestors.length - 2; i >= 0; i--) {
const node = ancestors[i];
if (node && SCOPE_NODE_TYPES.has(node.type)) return node;
}
return null;
}
/** Scope chain innermost-first, derived from the acorn-walk ancestors array. */
function scopeChainFromAncestors(ancestors: any[]): any[] {
const chain: any[] = [];
for (let i = ancestors.length - 1; i >= 0; i--) {
const node = ancestors[i];
if (node && SCOPE_NODE_TYPES.has(node.type)) chain.push(node);
}
return chain;
}
// ── Target bindings ───────────────────────────────────────────────────────────
function addBinding(
bindings: TargetBindings,
scopeNode: any,
name: string,
selector: string,
): void {
let scoped = bindings.get(scopeNode);
if (!scoped) {
scoped = new Map();
bindings.set(scopeNode, scoped);
}
if (!scoped.has(name)) scoped.set(name, selector);
}
function lookupBindingFromAncestors(
name: string,
ancestors: any[],
bindings: TargetBindings,
): string | null {
for (const scopeNode of scopeChainFromAncestors(ancestors)) {
const selector = bindings.get(scopeNode)?.get(name);
if (selector !== undefined) return selector;
}
// Program-scope bindings are stored under null (enclosingScopeNodeFromAncestors
// returns null when no function wrapper exists — the common case in HF scripts).
return bindings.get(null)?.get(name) ?? null;
}
function isFunctionNode(node: any): boolean {
return (
node?.type === "ArrowFunctionExpression" ||
node?.type === "FunctionExpression" ||
node?.type === "FunctionDeclaration"
);
}
function resolveCollectionSelector(
node: any,
ancestors: any[],
scope: ScopeBindings,
bindings: TargetBindings,
): string | null {
if (node?.type === "Identifier")
return lookupBindingFromAncestors(node.name, ancestors, bindings);
if (node?.type === "CallExpression") return selectorFromQueryCall(node, scope);
return null;
}
function collectScopeBindings(ast: any): ScopeBindings {
const bindings = new Map<string, number | string | boolean>();
// This compact resolver is intentionally conservative: it does not carry a
// full lexical environment. If the same identifier has different constant
// values in separate function/IIFE scopes, treating either value as global
// corrupts every tween in the other scope. Mark that name ambiguous so its
// expressions stay __raw and timing-sensitive lint rules skip them.
const ambiguousBindings = new Set<string>();
// Const ARRAY/OBJECT literals are kept as AST nodes for member/index folding
// (resolveMemberNode), exposed to resolveNode via the CONST_NODES side-table.
const constNodes: ConstNodes = new Map();
Object.defineProperty(bindings, CONST_NODES, { value: constNodes, enumerable: false });
acornWalk.simple(ast, {
VariableDeclarator(node: any) {
const name = node.id?.name;
const init = node.init;
if (!name || !init) return;
if (init.type === "ArrayExpression" || init.type === "ObjectExpression") {
constNodes.set(name, init);
return;
}
const val = resolveNode(init, bindings);
if (val === undefined || ambiguousBindings.has(name)) return;
const existing = bindings.get(name);
if (existing !== undefined && existing !== val) {
bindings.delete(name);
ambiguousBindings.add(name);
} else if (existing === undefined) {
bindings.set(name, val);
}
},
});
return bindings;
}
/**
* Build a lexically-scoped index of element variables → selector.
* Pass 1: direct DOM-lookup assignments.
* Pass 2: forEach/map callback params whose collection's selector is known.
*/
function collectTargetBindings(ast: any, scope: ScopeBindings): TargetBindings {
const bindings: TargetBindings = new Map();
acornWalk.ancestor(ast, {
VariableDeclarator(node: any, _: unknown, ancestors: any[]) {
const name = node.id?.name;
const selector = selectorFromQueryCall(node.init, scope);
if (name && selector !== null) {
addBinding(bindings, enclosingScopeNodeFromAncestors(ancestors), name, selector);
}
},
AssignmentExpression(node: any, _: unknown, ancestors: any[]) {
const left = node.left;
const selector = selectorFromQueryCall(node.right, scope);
if (left?.type === "Identifier" && selector !== null) {
addBinding(bindings, enclosingScopeNodeFromAncestors(ancestors), left.name, selector);
}
},
} as any);
// Pass 2: forEach/map callback params take the collection's selector.
acornWalk.ancestor(ast, {
// fallow-ignore-next-line complexity
CallExpression(node: any, _: unknown, ancestors: any[]) {
const callee = node.callee;
if (
callee?.type === "MemberExpression" &&
callee.property?.type === "Identifier" &&
ITERATION_METHODS.has(callee.property.name)
) {
const collectionSelector = resolveCollectionSelector(
callee.object,
ancestors,
scope,
bindings,
);
const fn = node.arguments?.[0];
const param = fn?.params?.[0];
if (collectionSelector && param?.type === "Identifier" && isFunctionNode(fn)) {
addBinding(bindings, fn, param.name, collectionSelector);
}
}
},
} as any);
// Pass 3: collection ALIASES inherit their source collection's selector.
// `const lead = glyphs[0]`, `const rest = glyphs.slice(1)`,
// `const some = glyphs.filter(...)` all still target the same selector
// (`.glyph`). Per-element identity isn't statically recoverable (DOM-sized
// collection), but the selector + stagger should not read as __unresolved__.
const COLLECTION_ALIAS_METHODS = new Set(["slice", "filter", "concat", "reverse"]);
acornWalk.ancestor(ast, {
// fallow-ignore-next-line complexity
VariableDeclarator(node: any, _: unknown, ancestors: any[]) {
const name = node.id?.name;
const init = node.init;
if (!name || !init) return;
let sourceVar: string | undefined;
// x = coll[i]
if (init.type === "MemberExpression" && init.object?.type === "Identifier") {
sourceVar = init.object.name;
}
// x = coll.slice(...) / coll.filter(...)
else if (
init.type === "CallExpression" &&
init.callee?.type === "MemberExpression" &&
init.callee.object?.type === "Identifier" &&
init.callee.property?.type === "Identifier" &&
COLLECTION_ALIAS_METHODS.has(init.callee.property.name)
) {
sourceVar = init.callee.object.name;
}
if (!sourceVar) return;
const selector = lookupBindingFromAncestors(sourceVar, ancestors, bindings);
if (selector)
addBinding(bindings, enclosingScopeNodeFromAncestors(ancestors), name, selector);
},
} as any);
return bindings;
}
// fallow-ignore-next-line complexity
function resolveTargetSelector(
node: any,
ancestors: any[],
scope: ScopeBindings,
bindings: TargetBindings,
): string | null {
if (!node) return null;
if (node.type === "StringLiteral" || node.type === "Literal") {
return typeof node.value === "string" ? node.value : null;
}
if (node.type === "Identifier") {
return lookupBindingFromAncestors(node.name, ancestors, bindings);
}
if (node.type === "CallExpression") {
return selectorFromQueryCall(node, scope);
}
if (node.type === "ArrayExpression") {
const parts = node.elements
.map((el: any) => resolveTargetSelector(el, ancestors, scope, bindings))
.filter((s: string | null): s is string => typeof s === "string" && s.length > 0);
return parts.length > 0 ? parts.join(", ") : null;
}
if (node.type === "MemberExpression" && node.object?.type === "Identifier") {
return lookupBindingFromAncestors(node.object.name, ancestors, bindings);
}
return null;
}
/**
* Classify an otherwise-unresolved tween target that is a plain object literal
* (`tl.to({}, …)`) or a proxy object (`tl.to(s, {onUpdate})`). Returns a
* descriptive pseudo-selector or null when the target isn't an object proxy.
*
* - Empty object literal → "dwell/hold" (a timing-only spacer tween, #11).
* - Proxy with onUpdate that writes a DOM attribute/style → "proxy → <attr>"
* (best-effort #5; the channel name is parsed from the onUpdate body).
*/
function describeProxyTarget(targetNode: any, varsNode: any, scope: ScopeBindings): string | null {
// Resolve an Identifier proxy (const s = {u:0}) to its object literal.
const objNode =
targetNode?.type === "ObjectExpression"
? targetNode
: targetNode?.type === "Identifier"
? resolveConstNode(targetNode, scope)
: undefined;
if (objNode?.type !== "ObjectExpression") return null;
const onUpdate = findPropertyNode(varsNode, "onUpdate");
const driven = onUpdate ? drivenDomChannel(onUpdate) : undefined;
if (driven) return `proxy → ${driven}`;
// Empty / proxy object with no resolvable DOM write ⇒ a timing spacer.
return "dwell/hold";
}
/** True for `el.style.foo = …` (nested-member style write). */
function isStyleAssignmentTarget(left: any): boolean {
return (
left?.type === "MemberExpression" &&
left.object?.type === "MemberExpression" &&
left.object.property?.name === "style" &&
!!left.property?.name
);
}
/** Best-effort: find the DOM attribute/style channel an onUpdate body writes. */
// fallow-ignore-next-line complexity
function drivenDomChannel(fnNode: any): string | undefined {
let found: string | undefined;
acornWalk.simple(fnNode, {
CallExpression(node: any) {
// setAttribute("d" | "points" | "stroke-dashoffset", …)
if (
node.callee?.type === "MemberExpression" &&
node.callee.property?.name === "setAttribute" &&
typeof node.arguments?.[0]?.value === "string"
) {
found ??= node.arguments[0].value;
}
},
AssignmentExpression(node: any) {
// el.style.foo = … / el.setAttribute-less style writes
const left = node.left;
if (isStyleAssignmentTarget(left)) found ??= `style.${left.property.name}`;
},
});
return found;
}
// ── ObjectExpression utilities ────────────────────────────────────────────────
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.value;
}
return undefined;
}
/**
* Extract raw source text for a property value — the offset-splice primitive.
* Equivalent to `recast.print(node).code` for unmodified nodes.
*/
function extractRawPropertySource(
varsArgNode: any,
key: string,
source: string,
): string | undefined {
const node = findPropertyNode(varsArgNode, key);
return node ? source.slice(node.start, node.end) : undefined;
}
// fallow-ignore-next-line complexity
function objectExpressionToRecord(
node: any,
scope: ScopeBindings,
source: string,
): Record<string, unknown> {
const result: Record<string, unknown> = {};
if (node?.type !== "ObjectExpression") return result;
for (const prop of node.properties ?? []) {
if (!isObjectProperty(prop)) continue;
const key = prop.key?.name ?? prop.key?.value;
if (!key) continue;
const resolved = resolveNode(prop.value, scope);
if (resolved !== undefined) {
result[key] = resolved;
} else {
result[key] = `__raw:${source.slice(prop.value.start, prop.value.end)}`;
}
}
return result;
}
// ── Timeline detection ────────────────────────────────────────────────────────
function isGsapTimelineCall(node: any): boolean {
return (
node?.type === "CallExpression" &&
node.callee?.type === "MemberExpression" &&
node.callee.object?.name === "gsap" &&
node.callee.property?.name === "timeline"
);
}
interface TimelineDefaults {
ease?: string;
duration?: number;
}
// How the timeline is referred to in source. `identifier` is the canonical
// `const tl = ...` form; `member` is the inline
// `window.__timelines["scene"] = ...` form, where the timeline is the member
// expression itself.
type TimelineRef = { kind: "identifier"; name: string } | { kind: "member"; node: any };
interface TimelineDetection {
/** Identifier name for the canonical form, else null for member/none. */
timelineVar: string | null;
/** Structural reference: identifier or member expression. Null when none found. */
ref: TimelineRef | null;
timelineCount: number;
defaults?: TimelineDefaults;
}
/** The static string key of a member access (`window.__timelines["scene"]` -> "scene"), else null. */
function staticMemberKey(node: any): string | null {
if (!node || node.type !== "MemberExpression") return null;
if (node.computed) {
const p = node.property;
if (p?.type === "Literal" && typeof p.value === "string") return p.value;
return null;
}
return node.property?.type === "Identifier" ? node.property.name : null;
}
/** True when a member expression refers to a statically-resolvable timeline slot. */
function isStaticMemberRef(node: any): boolean {
return node?.type === "MemberExpression" && staticMemberKey(node) !== null;
}
/** Structural equality of two member-access nodes (object chain + static key), quote-insensitive. */
function sameMemberAccess(a: any, b: any): 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 is rooted at: identifier name, or the member source as written. */
function timelineRootSource(ref: TimelineRef, script: string): string {
return ref.kind === "identifier" ? ref.name : script.slice(ref.node.start, ref.node.end);
}
function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
// fallow-ignore-next-line complexity
function extractTimelineDefaults(
callNode: any,
scope: ScopeBindings,
): TimelineDefaults | undefined {
const arg = callNode.arguments?.[0];
if (!arg || arg.type !== "ObjectExpression") return undefined;
const defaultsProp = arg.properties?.find(
(p: any) => isObjectProperty(p) && propKeyName(p) === "defaults",
);
if (!defaultsProp?.value || defaultsProp.value.type !== "ObjectExpression") return undefined;
const result: TimelineDefaults = {};
for (const prop of defaultsProp.value.properties ?? []) {
if (!isObjectProperty(prop)) continue;
const key = propKeyName(prop);
const val = resolveNode(prop.value, scope);
if (key === "ease" && typeof val === "string") result.ease = val;
if (key === "duration" && typeof val === "number") result.duration = val;
}
return Object.keys(result).length > 0 ? result : undefined;
}
function findTimelineVar(ast: any, 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();
acornWalk.simple(ast, {
VariableDeclarator(node: any) {
if (isGsapTimelineCall(node.init)) {
timelineCount += 1;
if (!ref && node.id?.type === "Identifier") {
timelineVar = node.id.name;
ref = { kind: "identifier", name: node.id.name };
defaults = extractTimelineDefaults(node.init, emptyScope);
}
}
},
AssignmentExpression(node: any) {
if (isGsapTimelineCall(node.right)) {
timelineCount += 1;
if (!ref) {
const left = node.left;
if (left?.type === "Identifier") {
timelineVar = left.name;
ref = { kind: "identifier", name: left.name };
defaults = extractTimelineDefaults(node.right, emptyScope);
} else if (isStaticMemberRef(left)) {
// Inline form: `window.__timelines["scene"] = gsap.timeline(...)`.
ref = { kind: "member", node: left };
defaults = extractTimelineDefaults(node.right, emptyScope);
}
}
}
},
});
return { timelineVar, ref, timelineCount, defaults };
}
// ── Tween call collection ─────────────────────────────────────────────────────
/** Keys stored on dedicated GsapAnimation fields (not in properties/extras). */
const BUILTIN_VAR_KEYS = new Set(["duration", "ease", "delay"]);
/** Keys never preserved (callbacks / advanced patterns). */
const DROPPED_VAR_KEYS = new Set(["onComplete", "onStart", "onUpdate", "onRepeat"]);
/** Keys that go in `extras` — non-editable GSAP config that must survive round-trips. */
const EXTRAS_KEYS = new Set([
"stagger",
"yoyo",
"repeat",
"repeatDelay",
"snap",
"overwrite",
"immediateRender",
]);
export interface TweenCallInfo {
node: any;
/** acorn-walk ancestor array at the call site (root→call, call is last). */
ancestors: any[];
method: GsapMethod;
selector: string;
varsArg: any;
fromArg?: any;
positionArg?: any;
/** True for a base `gsap.set(...)` (off-timeline) rather than `tl.set(...)`. */
global?: boolean;
}
/** True when the callee chain is rooted at the timeline reference (identifier or member). */
function isTimelineRootedCall(callNode: any, ref: TimelineRef): boolean {
let obj = callNode.callee?.object;
while (obj?.type === "CallExpression") {
obj = obj.callee?.object;
}
if (ref.kind === "identifier") return obj?.type === "Identifier" && obj.name === ref.name;
return sameMemberAccess(obj, ref.node);
}
/**
* Pre-order recursive walk for tween collection.
*
* acorn-walk is POST-order (visitor fires after children), which reverses
* chained calls vs recast.types.visit (PRE-order). We need pre-order to
* match the golden ordering where the outermost chained call appears first.
*/
function findAllTweenCalls(
ast: any,
ref: TimelineRef,
scope: ScopeBindings,
targetBindings: TargetBindings,
): TweenCallInfo[] {
const results: TweenCallInfo[] = [];
// fallow-ignore-next-line complexity
function visit(node: any, ancestors: readonly any[]): void {
if (!node || typeof node !== "object") return;
const nodeAncestors = [...ancestors, node];
// Fire BEFORE children (pre-order) so chained outer calls come first.
if (node.type === "CallExpression") {
const callee = node.callee;
// A base `gsap.set("#sel", props)` is an off-timeline static hold. Parse
// it as an editable global `set` so static values round-trip and re-edit
// in place. Variable-target holds stay surrounding source.
const gsapSetArg = node.arguments?.[0];
const isGlobalSet =
callee?.type === "MemberExpression" &&
callee.object?.type === "Identifier" &&
callee.object.name === "gsap" &&
callee.property?.type === "Identifier" &&
callee.property.name === "set" &&
(gsapSetArg?.type === "StringLiteral" ||
(gsapSetArg?.type === "Literal" && typeof gsapSetArg.value === "string"));
if (
callee?.type === "MemberExpression" &&
callee.property?.type === "Identifier" &&
(isTimelineRootedCall(node, ref) || isGlobalSet) &&
GSAP_METHODS.has(callee.property.name)
) {
const method = callee.property.name;
const args = node.arguments;
const selectorValue =
args.length >= 1
? (resolveTargetSelector(args[0], nodeAncestors, scope, targetBindings) ??
"__unresolved__")
: "__unresolved__";
if (method === "fromTo" && args.length >= 3) {
results.push({
node,
ancestors: nodeAncestors,
method: "fromTo",
selector: selectorValue,
fromArg: args[1],
varsArg: args[2],
positionArg: args[3],
});
} else if (method !== "fromTo" && args.length >= 2) {
results.push({
node,
ancestors: nodeAncestors,
method: method as GsapMethod,
selector: selectorValue,
varsArg: args[1],
positionArg: args[2],
...(isGlobalSet ? { global: true } : {}),
});
}
}
}
// Traverse children. Object.keys preserves insertion order, so callee
// comes before arguments in acorn's CallExpression nodes.
for (const key of Object.keys(node)) {
if (key === "type" || key === "start" || key === "end" || key === "loc") continue;
const child = (node as any)[key];
if (Array.isArray(child)) {
for (const item of child) {
if (item && typeof item === "object" && item.type) visit(item, nodeAncestors);
}
} else if (child && typeof child === "object" && (child as any).type) {
visit(child, nodeAncestors);
}
}
}
visit(ast, []);
return results;
}
// ── Keyframes parsing ─────────────────────────────────────────────────────────
const PERCENTAGE_KEY_RE = /^(\d+(?:\.\d+)?)%$/;
function tryResolveStringProp(propValue: any, scope: ScopeBindings): string | undefined {
const val = resolveNode(propValue, scope);
return typeof val === "string" ? val : undefined;
}
// fallow-ignore-next-line complexity
function parsePercentageKeyframes(
node: any,
scope: ScopeBindings,
source: string,
): GsapKeyframesData {
const keyframes: GsapPercentageKeyframe[] = [];
let ease: string | undefined;
let easeEach: string | undefined;
for (const prop of node.properties ?? []) {
if (prop.type !== "ObjectProperty" && prop.type !== "Property") continue;
const key = prop.key?.value ?? prop.key?.name;
if (typeof key !== "string") continue;
const pctMatch = PERCENTAGE_KEY_RE.exec(key);
if (pctMatch) {
const percentage = Number.parseFloat(pctMatch[1] ?? "0");
const record = objectExpressionToRecord(prop.value, scope, source);
const properties: Record<string, number | string> = {};
let kfEase: string | undefined;
for (const [k, v] of Object.entries(record)) {
if (k === "ease" && typeof v === "string") {
kfEase = v;
} else if (typeof v === "number" || typeof v === "string") {
properties[k] = v;
}
}
keyframes.push({ percentage, properties, ...(kfEase ? { ease: kfEase } : {}) });
} else if (key === "ease") {
ease = tryResolveStringProp(prop.value, scope) ?? ease;
} else if (key === "easeEach") {
easeEach = tryResolveStringProp(prop.value, scope) ?? easeEach;
}
}
keyframes.sort((a, b) => a.percentage - b.percentage);
return {
format: "percentage",
keyframes,
...(ease ? { ease } : {}),
...(easeEach ? { easeEach } : {}),
};
}
// fallow-ignore-next-line complexity
function computeKeyframesTotalDuration(
varsNode: any,
scope: ScopeBindings,
source: string,
): number | undefined {
const kfNode = (varsNode.properties ?? []).find(
(p: any) => (p.key?.name ?? p.key?.value) === "keyframes",
)?.value;
if (!kfNode || kfNode.type !== "ArrayExpression") return undefined;
let total = 0;
for (const el of kfNode.elements ?? []) {
if (!el || el.type !== "ObjectExpression") continue;
const r = objectExpressionToRecord(el, scope, source);
if (typeof r.duration === "number") total += r.duration;
}
return total > 0 ? total : undefined;
}
// fallow-ignore-next-line complexity
function parseObjectArrayKeyframes(
node: any,
scope: ScopeBindings,
source: string,
): GsapKeyframesData {
const elements = node.elements ?? [];
const raw: Array<{
properties: Record<string, number | string>;
duration?: number;
ease?: string;
}> = [];
for (const el of elements) {
if (!el || el.type !== "ObjectExpression") continue;
const record = objectExpressionToRecord(el, scope, source);
const properties: Record<string, number | string> = {};
let duration: number | undefined;
let ease: string | undefined;
for (const [k, v] of Object.entries(record)) {
if (k === "duration" && typeof v === "number") {
duration = v;
} else if (k === "ease" && typeof v === "string") {
ease = v;
} else if (typeof v === "number" || typeof v === "string") {
properties[k] = v;
}
}
raw.push({ properties, duration, ease });
}
const totalDuration = raw.reduce((sum, r) => sum + (r.duration ?? 0), 0);
const keyframes: GsapPercentageKeyframe[] = [];
if (totalDuration > 0) {
let cumulative = 0;
for (const entry of raw) {
cumulative += entry.duration ?? 0;
const percentage = Math.round((cumulative / totalDuration) * 100);
keyframes.push({
percentage,
properties: entry.properties,
...(entry.ease ? { ease: entry.ease } : {}),
});
}
} else {
for (let i = 0; i < raw.length; i++) {
const entry = raw[i];
if (!entry) continue;
const percentage = raw.length > 1 ? Math.round((i / (raw.length - 1)) * 100) : 0;
keyframes.push({
percentage,
properties: entry.properties,
...(entry.ease ? { ease: entry.ease } : {}),
});
}
}
return { format: "object-array", keyframes };
}
// fallow-ignore-next-line complexity
function parseSimpleArrayKeyframes(node: any, scope: ScopeBindings): GsapKeyframesData {
const arrayProps: Map<string, (number | string)[]> = new Map();
let ease: string | undefined;
let easeEach: string | undefined;
for (const prop of node.properties ?? []) {
if (prop.type !== "ObjectProperty" && prop.type !== "Property") continue;
const key = prop.key?.name ?? prop.key?.value;
if (typeof key !== "string") continue;
if (prop.value?.type === "ArrayExpression") {
const values: (number | string)[] = [];
for (const el of prop.value.elements ?? []) {
const val = resolveNode(el, scope);
if (typeof val === "number" || typeof val === "string") {
values.push(val);
}
}
if (values.length > 0) arrayProps.set(key, values);
} else if (key === "ease") {
ease = tryResolveStringProp(prop.value, scope) ?? ease;
} else if (key === "easeEach") {
easeEach = tryResolveStringProp(prop.value, scope) ?? easeEach;
}
}
const maxLen = Math.max(...[...arrayProps.values()].map((a) => a.length), 0);
const keyframes: GsapPercentageKeyframe[] = [];
for (let i = 0; i < maxLen; i++) {
const percentage = maxLen > 1 ? Math.round((i / (maxLen - 1)) * 100) : 0;
const properties: Record<string, number | string> = {};
for (const [key, values] of arrayProps) {
if (i < values.length) properties[key] = values[i] as number | string;
}
keyframes.push({ percentage, properties });
}
return {
format: "simple-array",
keyframes,
...(ease ? { ease } : {}),
...(easeEach ? { easeEach } : {}),
};
}
// fallow-ignore-next-line complexity
function parseKeyframesNode(
node: any,
scope: ScopeBindings,
source: string,
): GsapKeyframesData | undefined {
if (!node) return undefined;
if (node.type === "ArrayExpression") {
return parseObjectArrayKeyframes(node, scope, source);
}
if (node.type !== "ObjectExpression") return undefined;
const props = node.properties ?? [];
let hasPercentageKey = false;
let hasArrayValue = false;
for (const prop of props) {
if (prop.type !== "ObjectProperty" && prop.type !== "Property") continue;
const key = prop.key?.value ?? prop.key?.name;
if (typeof key === "string" && PERCENTAGE_KEY_RE.test(key)) {
hasPercentageKey = true;
break;
}
if (prop.value?.type === "ArrayExpression") {
hasArrayValue = true;
}
}
if (hasPercentageKey) return parsePercentageKeyframes(node, scope, source);
if (hasArrayValue) return parseSimpleArrayKeyframes(node, scope);
return undefined;
}
// ── MotionPath parsing ────────────────────────────────────────────────────────
interface MotionPathParseResult {
arcPath: ArcPathConfig;
waypoints: Array<{ x: number; y: number }>;
}
// fallow-ignore-next-line complexity
function parseMotionPathNode(
node: any,
scope: ScopeBindings,
source: string,
): MotionPathParseResult | undefined {
if (!node) return undefined;
let pathNode: any;
let autoRotate: boolean | number = false;
let curviness = 1;
let isCubic = false;
if (node.type === "ObjectExpression") {
for (const prop of node.properties ?? []) {
if (!isObjectProperty(prop)) continue;
const key = propKeyName(prop);
if (key === "path") pathNode = prop.value;
else if (key === "autoRotate") {
const val = resolveNode(prop.value, scope);
autoRotate = typeof val === "number" ? val : val === true;
} else if (key === "curviness") {
const val = resolveNode(prop.value, scope);
if (typeof val === "number") curviness = val;
} else if (key === "type") {
const val = resolveNode(prop.value, scope);
if (val === "cubic") isCubic = true;
}
}
} else if (node.type === "ArrayExpression") {
pathNode = node;
}
if (!pathNode || pathNode.type !== "ArrayExpression") return undefined;
const elements = pathNode.elements ?? [];
const coords: Array<{ x: number; y: number }> = [];
for (const elem of elements) {
if (!elem || elem.type !== "ObjectExpression") continue;
const rec = objectExpressionToRecord(elem, scope, source);
const x = typeof rec.x === "number" ? rec.x : undefined;
const y = typeof rec.y === "number" ? rec.y : undefined;
if (x !== undefined && y !== undefined) coords.push({ x, y });
}
return buildArcPath(coords, curviness, autoRotate, isCubic);
}
// ── Animation assembly ────────────────────────────────────────────────────────
// fallow-ignore-next-line complexity
function tweenCallToAnimation(
call: TweenCallInfo,
scope: ScopeBindings,
source: string,
): Omit<GsapAnimation, "id"> {
const vars = objectExpressionToRecord(call.varsArg, scope, source);
const properties: Record<string, number | string> = {};
const extras: Record<string, unknown> = {};
let keyframesData: GsapKeyframesData | undefined;
let hasUnresolvedKeyframes = false;
let motionPathResult: MotionPathParseResult | undefined;
for (const [key, val] of Object.entries(vars)) {
if (BUILTIN_VAR_KEYS.has(key)) continue;
if (DROPPED_VAR_KEYS.has(key)) continue;
if (key === "keyframes") {
const kfNode = findPropertyNode(call.varsArg, "keyframes");
keyframesData = parseKeyframesNode(kfNode, scope, source);
if (!keyframesData && kfNode) hasUnresolvedKeyframes = true;
continue;
}
if (key === "motionPath") {
const mpNode = findPropertyNode(call.varsArg, "motionPath");
motionPathResult = parseMotionPathNode(mpNode, scope, source);
continue;
}
if (key === "easeEach") continue;
if (EXTRAS_KEYS.has(key)) {
const rawSource = extractRawPropertySource(call.varsArg, key, source);
if (rawSource !== undefined) {
extras[key] = `__raw:${rawSource}`;
} else if (val !== undefined) {
extras[key] = val;
}
continue;
}
if (typeof val === "number" || typeof val === "string") {
properties[key] = val;
}
}
if (keyframesData && typeof vars.easeEach === "string") {
keyframesData.easeEach = vars.easeEach as string;
}
if (motionPathResult) {
const { waypoints } = motionPathResult;
if (!keyframesData) {
const kf: GsapPercentageKeyframe[] = waypoints.map((wp, i) => ({
percentage: waypoints.length > 1 ? Math.round((i / (waypoints.length - 1)) * 100) : 0,
properties: { x: wp.x, y: wp.y },
}));
keyframesData = { format: "percentage", keyframes: kf };
} else {
const kfs = keyframesData.keyframes;
if (kfs.length === waypoints.length) {
for (let i = 0; i < kfs.length; i++) {
const kf = kfs[i];
const wp = waypoints[i];
if (kf && wp) {
kf.properties.x = wp.x;
kf.properties.y = wp.y;
}
}
}
}
}
let fromProperties: Record<string, number | string> | undefined;
if (call.method === "fromTo" && call.fromArg) {
fromProperties = {};
const fromVars = objectExpressionToRecord(call.fromArg, scope, source);
for (const [key, val] of Object.entries(fromVars)) {
if (typeof val === "number" || typeof val === "string") {
fromProperties[key] = val;
}
}
}
const hasPositionArg = !!call.positionArg;
const posVal = hasPositionArg ? extractLiteralValue(call.positionArg, scope) : 0;
const position: number | string =
typeof posVal === "number"
? posVal
: typeof posVal === "string"
? posVal
: hasPositionArg
? `__raw:${source.slice(call.positionArg.start, call.positionArg.end)}`
: 0;
let duration = typeof vars.duration === "number" ? vars.duration : undefined;
const ease = typeof vars.ease === "string" ? vars.ease : undefined;
if (duration === undefined && keyframesData) {
duration = computeKeyframesTotalDuration(call.varsArg, scope, source);
}
// Relabel object-proxy / empty-target tweens so they don't read as bare
// __unresolved__: a dwell/hold spacer or an onUpdate-driven DOM channel (#5/#11).
let selector = call.selector;
if (selector === "__unresolved__") {
const proxyLabel = describeProxyTarget(call.node.arguments?.[0], call.varsArg, scope);
if (proxyLabel) selector = proxyLabel;
}
const anim: Omit<GsapAnimation, "id"> = {
targetSelector: selector,
method: call.method,
position,
properties,
fromProperties,
duration,
ease,
};
if (!hasPositionArg) anim.implicitPosition = true;
let group = classifyTweenPropertyGroup(properties);
if (!group && keyframesData) {
const kfProps: Record<string, unknown> = {};
for (const kf of keyframesData.keyframes) {
for (const k of Object.keys(kf.properties)) kfProps[k] = true;
}
group = classifyTweenPropertyGroup(kfProps);
}
if (group) anim.propertyGroup = group;
if (call.global) anim.global = true;
if (Object.keys(extras).length > 0) anim.extras = extras;
if (keyframesData) anim.keyframes = keyframesData;
if (motionPathResult) anim.arcPath = motionPathResult.arcPath;
if (hasUnresolvedKeyframes) anim.hasUnresolvedKeyframes = true;
if (selector === "__unresolved__") anim.hasUnresolvedSelector = true;
const provenance = readProvenance(call.node);
if (provenance) anim.provenance = provenance;
return anim;
}
// ── Stagger annotation (read path) ────────────────────────────────────────────
/**
* Pull the per-element stagger amount out of a captured `extras.stagger` value.
* GSAP staggers are either a number (`0.08`) or an object (`{ each: 0.012, ... }`).
* The value arrives here as the round-trip form `__raw:<source>`; we only need
* the leading numeric / `each:` figure for the annotation. Returns undefined for
* non-numeric (function) staggers — there's nothing honest to print.
*/
function staggerAmount(raw: unknown): number | undefined {
if (typeof raw === "number") return raw;
if (typeof raw !== "string") return undefined;
const src = raw.startsWith("__raw:") ? raw.slice(6) : raw;
const m = /(?:each\s*:\s*)?(-?\d+(?:\.\d+)?)/.exec(src);
if (!m) return undefined;
const n = Number.parseFloat(m[1]!);
return Number.isFinite(n) ? n : undefined;
}
/** Rest-pose value GSAP animates to/from for an unspecified endpoint. */
function restValue(prop: string): number {
return prop === "opacity" || prop.startsWith("scale") ? 1 : 0;
}
/**
* Honest from/to keyframes for a flat staggered tween, mirroring how a flat
* tween renders: `from()` plays vars -> rest, `to()` plays rest -> vars, `fromTo`
* plays its authored endpoints. A synthetic `stagger` channel rides on both
* keyframes so the per-element cascade is visible even when the tween lands on
* the rest pose (a 1->1 collection that would otherwise read as a no-op).
*/
function staggeredKeyframes(
anim: Omit<GsapAnimation, "id">,
each: number,
): GsapPercentageKeyframe[] {
const vars = { ...anim.properties };
let from: Record<string, number | string>;
let to: Record<string, number | string>;
if (anim.method === "fromTo") {
from = { ...(anim.fromProperties ?? {}) };
to = vars;
} else if (anim.method === "from") {
from = vars;
to = {};
for (const k of Object.keys(vars)) to[k] = restValue(k);
} else {
from = { ...(anim.fromProperties ?? {}) };
for (const k of Object.keys(vars)) if (from[k] === undefined) from[k] = restValue(k);
to = vars;
}
return [
{ percentage: 0, properties: { ...from, stagger: each } },
{ percentage: 100, properties: { ...to, stagger: each } },
];
}
/**
* Read-path honesty pass: a tween with a `stagger` targets a per-element
* collection that animates one element at a time. A flat staggered tween that
* lands on (or starts from) the rest pose otherwise renders as a misleading
* X->X no-op. Surface real from/to keyframes plus the per-element `stagger`
* amount so the reader can tell the collection DOES animate and roughly how.
*
* Read path only — the write/serialize path ignores `keyframes` and keeps the
* literal `extras.stagger` source, so round-trips are untouched. Keyframed /
* motionPath tweens already surface their motion, so they're left alone.
*/
function annotateStaggeredCollections(anims: Omit<GsapAnimation, "id">[]): void {
for (const anim of anims) {
if (anim.keyframes || anim.arcPath) continue;
const each = staggerAmount(anim.extras?.stagger);
if (each === undefined) continue;
anim.keyframes = { format: "percentage", keyframes: staggeredKeyframes(anim, each) };
}
}
// ── Timeline position resolution ─────────────────────────────────────────────
const GSAP_DEFAULT_DURATION = 0.5;
// fallow-ignore-next-line complexity
function resolvePositionString(pos: string, cursor: number, prevStart: number): number | null {
const trimmed = pos.trim();
if (trimmed === "") return cursor;
if (trimmed.startsWith("+=")) {
const n = Number.parseFloat(trimmed.slice(2));
return Number.isFinite(n) ? cursor + n : null;
}
if (trimmed.startsWith("-=")) {
const n = Number.parseFloat(trimmed.slice(2));
return Number.isFinite(n) ? cursor - n : null;
}
if (trimmed === "<") return prevStart;
if (trimmed === ">") return cursor;
if (trimmed.startsWith("<")) {
const n = Number.parseFloat(trimmed.slice(1));
return Number.isFinite(n) ? prevStart + n : null;
}
if (trimmed.startsWith(">")) {
const n = Number.parseFloat(trimmed.slice(1));
return Number.isFinite(n) ? cursor + n : null;
}
const n = Number.parseFloat(trimmed);
return Number.isFinite(n) ? n : null;
}
// ── set() pre-state seeding (#3 in eval) ──────────────────────────────────────
/**
* Collect `gsap.set(target, {prop: v})` calls into selector → {prop: value}.
* These run before the (paused) timeline builds, establishing the initial DOM
* state. tl.set(...) calls are already in the animation list, so they're folded
* in during the seeding walk instead.
*/
function collectGsapSetStates(
ast: any,
scope: ScopeBindings,
bindings: TargetBindings,
source: string,
): Map<string, Record<string, number | string>> {
const states = new Map<string, Record<string, number | string>>();
acornWalk.ancestor(ast, {
// fallow-ignore-next-line complexity
CallExpression(node: any, _: unknown, ancestors: any[]) {
const callee = node.callee;
if (
callee?.type !== "MemberExpression" ||
callee.object?.name !== "gsap" ||
callee.property?.name !== "set"
)
return;
const selector = resolveTargetSelector(node.arguments?.[0], ancestors, scope, bindings);
if (!selector) return;
const rec = objectExpressionToRecord(node.arguments?.[1], scope, source);
const props: Record<string, number | string> = states.get(selector) ?? {};
for (const [k, v] of Object.entries(rec)) {
if (typeof v === "number" || typeof v === "string") props[k] = v;
}
states.set(selector, props);
},
} as any);
return states;
}
/**
* Seed each tween's start keyframe from the most recent set() value on the same
* target. Without this, `set(scaleY:0)` then `.to(scaleY:1)` reports the CSS
* default as the start, so a grow/fold/fade-from reads as a flat no-op.
*
* Walks animations in timeline order, tracking per-target current state from
* gsap.set() (pre-seeded) and tl.set() tweens. For a later to/from tween that
* lacks an explicit from-value for an animated prop, the tracked set() value
* becomes its from-keyframe.
*/
/** Overwrite `target` in place with `props`, returning it (for Map.set chaining). */
function mergeProps(
target: Record<string, number | string>,
props: Record<string, number | string>,
): Record<string, number | string> {
for (const [k, v] of Object.entries(props)) target[k] = v;
return target;
}
/**
* Fill in `anim.fromProperties` for a `.to()` tween's props that lack an
* explicit start, from the tracked pre-tween state. Mutates `anim` only when
* at least one prop is actually seeded.
*/
function seedFromPreState(
anim: Omit<GsapAnimation, "id">,
cur: Record<string, number | string>,
): void {
const from = { ...(anim.fromProperties ?? {}) };
let seeded = false;
for (const prop of Object.keys(anim.properties)) {
if (from[prop] === undefined && cur[prop] !== undefined) {
from[prop] = cur[prop];
seeded = true;
}
}
if (seeded) anim.fromProperties = from;
}
function seedSetStates(
anims: Omit<GsapAnimation, "id">[],
initial: Map<string, Record<string, number | string>>,
): void {
const state = new Map<string, Record<string, number | string>>();
for (const [sel, props] of initial) state.set(sel, { ...props });
for (const anim of anims) {
const sel = anim.targetSelector;
if (anim.method === "set") {
state.set(sel, mergeProps(state.get(sel) ?? {}, anim.properties));
continue;
}
// fromTo authors its own start explicitly — don't override it.
const cur = state.get(sel);
if (anim.method === "to" && cur) seedFromPreState(anim, cur);
// After a to/from tween, the target's state is the tween's END values.
state.set(sel, mergeProps(state.get(sel) ?? {}, anim.properties));
}
}
function applyTimelineDefaults(
anims: Omit<GsapAnimation, "id">[],
defaults?: TimelineDefaults,
): void {
if (!defaults) return;
for (const anim of anims) {
if (anim.method === "set") continue;
if (anim.duration === undefined && defaults.duration !== undefined) {
anim.duration = defaults.duration;
}
if (anim.ease === undefined && defaults.ease !== undefined) {
anim.ease = defaults.ease;
}
}
}
/** A source-ordered addLabel(name, position) definition. */
interface AddLabelDef {
name: string;
/** Raw position string/number, or undefined ⇒ label sits at current end. */
position: string | number | undefined;
/** Source-order key (parallel to anims), for interleaving. */
order: number;
}
/**
* Resolve a label-relative position string against a live label table.
* Handles "label", "label+=n", "label-=n". Unknown labels auto-create at the
* current playhead (cursor) — GSAP's behavior when a tween references a label
* that hasn't been added yet. Returns null when not a label form.
*/
function resolveLabelPosition(
pos: string,
labels: Map<string, number>,
cursor: number,
): number | null {
const m = /^([A-Za-z_$][\w$]*)\s*(?:([+-])=\s*([\d.]+))?$/.exec(pos.trim());
if (!m) return null;
const name = m[1]!;
let base = labels.get(name);
if (base === undefined) {
base = cursor; // auto-create label at the current end-of-timeline
labels.set(name, base);
}
if (m[2] && m[3]) {
const n = Number.parseFloat(m[3]);
if (Number.isFinite(n)) return m[2] === "+" ? base + n : base - n;
}
return base;
}
/** Resolve a single tween's unresolved start time against the live cursor/labels. */
function resolveAnimStart(
anim: Omit<GsapAnimation, "id">,
cursor: number,
prevStart: number,
labels: Map<string, number>,
): number | null {
if (anim.implicitPosition) return cursor;
if (typeof anim.position === "number") return anim.position;
if (typeof anim.position === "string") {
return (
resolveLabelPosition(anim.position, labels, cursor) ??
resolvePositionString(anim.position, cursor, prevStart)
);
}
return cursor;
}
function resolveTimelinePositions(
anims: Omit<GsapAnimation, "id">[],
labelDefs: AddLabelDef[] = [],
): void {
let cursor = 0;
let prevStart = 0;
const labels = new Map<string, number>();
// Interleave addLabel definitions with tweens by source order so labels are
// available exactly when later tweens reference them.
let labelIdx = 0;
const sortedLabels = [...labelDefs].sort((a, b) => a.order - b.order);
const defineLabel = (def: AddLabelDef): void => {
let value: number;
if (typeof def.position === "number") value = def.position;
else if (typeof def.position === "string") {
value = resolveLabelPosition(def.position, labels, cursor) ?? cursor;
} else value = cursor; // no position ⇒ end of timeline
labels.set(def.name, Math.max(0, value));
};
anims.forEach((anim, i) => {
// Apply any addLabel calls authored before this tween.
while (labelIdx < sortedLabels.length && sortedLabels[labelIdx]!.order <= i) {
defineLabel(sortedLabels[labelIdx]!);
labelIdx++;
}
// A global `gsap.set(...)` is off-timeline: applied once at load, not
// sequenced on the master timeline. It has no position arg, so pin it to 0
// and do not advance the cursor.
if (anim.method === "set" && anim.global) {
anim.resolvedStart = 0;
return;
}
const duration = anim.method === "set" ? 0 : (anim.duration ?? GSAP_DEFAULT_DURATION);
const start = resolveAnimStart(anim, cursor, prevStart, labels);
if (start != null) {
anim.resolvedStart = Math.max(0, start);
prevStart = anim.resolvedStart;
cursor = Math.max(cursor, anim.resolvedStart + duration);
}
});
// Any trailing addLabel calls (define for completeness; no tweens follow).
while (labelIdx < sortedLabels.length) defineLabel(sortedLabels[labelIdx++]!);
}
/**
* Collect `tl.addLabel(name, position)` calls and compute each one's `order` —
* the count of tween calls that precede it in source order — so positions can
* be interleaved against the sorted animation list in resolveTimelinePositions.
*/
function collectAddLabelDefs(
ast: any,
ref: TimelineRef,
scope: ScopeBindings,
sortedCalls: TweenCallInfo[],
): AddLabelDef[] {
const callLocs = sortedCalls.map((c) => c.node.callee?.property?.loc?.start);
const defs: AddLabelDef[] = [];
acornWalk.simple(ast, {
// fallow-ignore-next-line complexity
CallExpression(node: any) {
const callee = node.callee;
const objMatches =
ref.kind === "identifier"
? callee.object?.type === "Identifier" && callee.object.name === ref.name
: sameMemberAccess(callee.object, ref.node);
if (
callee?.type !== "MemberExpression" ||
!objMatches ||
callee.property?.name !== "addLabel"
)
return;
const nameNode = node.arguments?.[0];
const name = typeof nameNode?.value === "string" ? nameNode.value : undefined;
if (!name) return;
// position may be numeric, a label-relative string, or omitted.
const posVal = resolveNode(node.arguments?.[1], scope);
const position =
typeof posVal === "number" || typeof posVal === "string" ? posVal : undefined;
const labelLoc = callee.property?.loc?.start;
let order = sortedCalls.length;
if (labelLoc) {
order = callLocs.findIndex(
(l) =>
l &&
(l.line > labelLoc.line || (l.line === labelLoc.line && l.column > labelLoc.column)),
);
if (order === -1) order = sortedCalls.length;
}
defs.push({ name, position, order });
},
});
return defs;
}
function compareByLoc(a: TweenCallInfo, b: TweenCallInfo): number {
const aLoc = a.node.callee?.property?.loc?.start;
const bLoc = b.node.callee?.property?.loc?.start;
if (!aLoc || !bLoc) return 0;
return aLoc.line - bLoc.line || aLoc.column - bLoc.column;
}
// Inlined tweens carry a monotonic __hfOrder (clones share source loc, so loc
// can't order them); they sort by that, after all literal (loc-ordered) tweens.
function compareCallOrder(a: TweenCallInfo, b: TweenCallInfo): number {
const ao = a.node.__hfOrder;
const bo = b.node.__hfOrder;
if (ao === undefined && bo === undefined) return compareByLoc(a, b);
if (ao === undefined) return -1;
if (bo === undefined) return 1;
return ao - bo;
}
function sortBySourcePosition(calls: TweenCallInfo[]): void {
calls.sort(compareCallOrder);
}
// ── Stable ID generation ──────────────────────────────────────────────────────
function assignStableIds(anims: Omit<GsapAnimation, "id">[]): GsapAnimation[] {
const counts = new Map<string, number>();
return anims.map((anim) => {
const posKey =
typeof anim.position === "number"
? String(Math.round(anim.position * 1000))
: String(anim.position);
const groupSuffix = anim.propertyGroup ? `-${anim.propertyGroup}` : "";
const base = `${anim.targetSelector}-${anim.method}-${posKey}${groupSuffix}`;
const count = (counts.get(base) ?? 0) + 1;
counts.set(base, count);
const id = count === 1 ? base : `${base}-${count}`;
return { ...anim, id };
});
}
// ── Write-path internal parse ─────────────────────────────────────────────────
export interface ParsedGsapAcornForWrite {
ast: any;
timelineVar: string;
hasTimeline: boolean;
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 ref: TimelineRef = detection.ref ?? { kind: "identifier", name: "tl" };
const timelineVar = timelineRootSource(ref, script);
const calls = findAllTweenCalls(ast, ref, 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, hasTimeline: detection.ref !== null, located };
} catch {
return null;
}
}
// ── Public API ────────────────────────────────────────────────────────────────
/**
* Browser-safe equivalent of `parseGsapScript` (gsapParser.ts).
* Uses acorn + acorn-walk instead of recast + @babel/parser.
*/
export function parseGsapScriptAcorn(script: string): ParsedGsap {
try {
const ast = acorn.parse(script, {
ecmaVersion: "latest",
sourceType: "script",
locations: true,
});
const scope = collectScopeBindings(ast);
const detection = findTimelineVar(ast, scope);
const ref: TimelineRef = detection.ref ?? { kind: "identifier", name: "tl" };
const timelineVar = timelineRootSource(ref, script);
// Expand helper-built / bounded-loop timelines before analysis so their
// tweens resolve at true positions (read path only — the write path keeps
// original source nodes). Degrades to the un-inlined AST on any failure.
// Only identifier timelines use the helper-built pattern; inline member
// timelines have nothing to inline, so skip to avoid mis-rooting on member refs.
if (ref.kind === "identifier") {
try {
inlineComputedTimelines(ast, timelineVar, (node) => resolveNode(node, scope));
} catch {
/* fall back to current behavior */
}
}
const targetBindings = collectTargetBindings(ast, scope);
const calls = findAllTweenCalls(ast, ref, scope, targetBindings);
sortBySourcePosition(calls);
const rawAnims = calls.map((call) => tweenCallToAnimation(call, scope, script));
applyTimelineDefaults(rawAnims, detection.defaults);
// Seed tween start-keyframes from gsap.set()/tl.set() pre-states (read-only
// enrichment; the write path keeps source untouched for round-trip parity).
seedSetStates(rawAnims, collectGsapSetStates(ast, scope, targetBindings, script));
const labelDefs = collectAddLabelDefs(ast, ref, scope, calls);
resolveTimelinePositions(rawAnims, labelDefs);
// Honesty pass (read path only): make staggered collection tweens read as
// real per-element motion instead of a flat no-op. Done after positions so
// duration is settled; before ids so the annotation is part of the id.
annotateStaggeredCollections(rawAnims);
const animations = assignStableIds(rawAnims);
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 = "";
if (lastCallIdx !== -1) {
const afterLast = script.slice(lastCallIdx);
const endOfCall = afterLast.indexOf(";");
if (endOfCall !== -1) {
postamble = script.slice(lastCallIdx + endOfCall + 1).trim();
}
}
const result: ParsedGsap = { animations, timelineVar, preamble, postamble };
if (detection.timelineCount > 1) result.multipleTimelines = true;
if (detection.timelineCount > 0 && detection.ref === null)
result.unsupportedTimelinePattern = true;
return result;
} catch {
return { animations: [], timelineVar: "tl", preamble: "", postamble: "" };
}
}
// ── Label extraction (WS-C) ──────────────────────────────────────────────────
export interface GsapLabelEntry {
name: string;
position: number;
}
/**
* Extract all `tl.addLabel("name", position)` calls from a GSAP script.
*
* Returns labels in source order. Position must be a numeric literal; labels
* with non-numeric positions (e.g. label-relative offsets) are skipped.
*
* Pure — no side effects, no DOM, no Date.now.
*/
export function extractGsapLabels(script: string): GsapLabelEntry[] {
try {
const ast = acorn.parse(script, {
ecmaVersion: "latest",
sourceType: "script",
locations: true,
});
const scope = collectScopeBindings(ast);
const detection = findTimelineVar(ast, scope);
const ref: TimelineRef = detection.ref ?? { kind: "identifier", name: "tl" };
const labels: GsapLabelEntry[] = [];
acornWalk.simple(ast, {
// fallow-ignore-next-line complexity
ExpressionStatement(node: any) {
const expr = node.expression;
if (!expr || expr.type !== "CallExpression") return;
const callee = expr.callee;
// Match <timeline>.addLabel(...) for identifier or member timeline refs.
const objMatches =
ref.kind === "identifier"
? callee.object?.type === "Identifier" && callee.object.name === ref.name
: sameMemberAccess(callee.object, ref.node);
if (
callee?.type !== "MemberExpression" ||
!objMatches ||
callee.property?.name !== "addLabel"
)
return;
const args = expr.arguments ?? [];
const nameNode = args[0];
const posNode = args[1];
if (nameNode?.type !== "Literal" || typeof nameNode.value !== "string") return;
if (!posNode) return;
const pos = resolveNode(posNode, scope);
if (typeof pos !== "number" || !Number.isFinite(pos)) return;
labels.push({ name: nameNode.value, position: pos });
},
});
return labels;
} catch {
// Labels are best-effort/supplementary, not load-bearing — a malformed or
// unparseable script yields no labels rather than failing the caller.
return [];
}
}