@@ -376,12 +376,54 @@ interface TimelineDefaults {
376376 duration ?: number ;
377377}
378378
379+ // `identifier` is the canonical `const tl = …` form; `member` is the inline
380+ // `window.__timelines["scene"] = …` form (the timeline IS the member expression).
381+ type TimelineRef = { kind : "identifier" ; name : string } | { kind : "member" ; node : AstNode } ;
382+
379383interface TimelineDetection {
380384 timelineVar : string | null ;
385+ ref : TimelineRef | null ;
381386 timelineCount : number ;
382387 defaults ?: TimelineDefaults ;
383388}
384389
390+ /** The static string key of a member access (`window.__timelines["scene"]` → "scene"), else null. */
391+ function staticMemberKey ( node : AstNode ) : string | null {
392+ if ( ! node || node . type !== "MemberExpression" ) return null ;
393+ if ( node . computed ) {
394+ const p = node . property ;
395+ if ( p ?. type === "StringLiteral" ) return p . value ;
396+ if ( p ?. type === "Literal" && typeof p . value === "string" ) return p . value ;
397+ return null ;
398+ }
399+ return node . property ?. type === "Identifier" ? node . property . name : null ;
400+ }
401+
402+ function isStaticMemberRef ( node : AstNode ) : boolean {
403+ return node ?. type === "MemberExpression" && staticMemberKey ( node ) !== null ;
404+ }
405+
406+ /** Structural equality of two member accesses (object chain + static key), quote-insensitive. */
407+ function sameMemberAccess ( a : AstNode , b : AstNode ) : boolean {
408+ if ( a ?. type !== "MemberExpression" || b ?. type !== "MemberExpression" ) return false ;
409+ if ( staticMemberKey ( a ) !== staticMemberKey ( b ) || staticMemberKey ( a ) === null ) return false ;
410+ const ao = a . object ;
411+ const bo = b . object ;
412+ if ( ao ?. type === "Identifier" && bo ?. type === "Identifier" ) return ao . name === bo . name ;
413+ if ( ao ?. type === "MemberExpression" && bo ?. type === "MemberExpression" )
414+ return sameMemberAccess ( ao , bo ) ;
415+ return false ;
416+ }
417+
418+ /** The source string a tween call roots at: identifier name, or the member source as written. */
419+ function timelineRootSource ( ref : TimelineRef ) : string {
420+ return ref . kind === "identifier" ? ref . name : recast . print ( ref . node ) . code ;
421+ }
422+
423+ function escapeRegExp ( s : string ) : string {
424+ return s . replace ( / [ . * + ? ^ $ { } ( ) | [ \] \\ ] / g, "\\$&" ) ;
425+ }
426+
385427function extractTimelineDefaults (
386428 callNode : AstNode ,
387429 scope : ScopeBindings ,
@@ -401,15 +443,17 @@ function extractTimelineDefaults(
401443
402444function findTimelineVar ( ast : AstNode , scope ?: ScopeBindings ) : TimelineDetection {
403445 let timelineVar : string | null = null ;
446+ let ref : TimelineRef | null = null ;
404447 let timelineCount = 0 ;
405448 let defaults : TimelineDefaults | undefined ;
406449 const emptyScope : ScopeBindings = scope ?? new Map ( ) ;
407450 recast . types . visit ( ast , {
408451 visitVariableDeclarator ( path : AstPath ) {
409452 if ( isGsapTimelineCall ( path . node . init ) ) {
410453 timelineCount += 1 ;
411- if ( ! timelineVar ) {
412- timelineVar = path . node . id ?. name ?? null ;
454+ if ( ! ref && path . node . id ?. type === "Identifier" ) {
455+ timelineVar = path . node . id . name ;
456+ ref = { kind : "identifier" , name : path . node . id . name } ;
413457 defaults = extractTimelineDefaults ( path . node . init , emptyScope ) ;
414458 }
415459 }
@@ -418,16 +462,22 @@ function findTimelineVar(ast: AstNode, scope?: ScopeBindings): TimelineDetection
418462 visitAssignmentExpression ( path : AstPath ) {
419463 if ( isGsapTimelineCall ( path . node . right ) ) {
420464 timelineCount += 1 ;
421- if ( ! timelineVar ) {
465+ if ( ! ref ) {
422466 const left = path . node . left ;
423- if ( left ?. type === "Identifier" ) timelineVar = left . name ;
424- defaults = extractTimelineDefaults ( path . node . right , emptyScope ) ;
467+ if ( left ?. type === "Identifier" ) {
468+ timelineVar = left . name ;
469+ ref = { kind : "identifier" , name : left . name } ;
470+ defaults = extractTimelineDefaults ( path . node . right , emptyScope ) ;
471+ } else if ( isStaticMemberRef ( left ) ) {
472+ ref = { kind : "member" , node : left } ;
473+ defaults = extractTimelineDefaults ( path . node . right , emptyScope ) ;
474+ }
425475 }
426476 }
427477 this . traverse ( path ) ;
428478 } ,
429479 } ) ;
430- return { timelineVar, timelineCount, defaults } ;
480+ return { timelineVar, ref , timelineCount, defaults } ;
431481}
432482
433483// ── Find All Tween Calls ────────────────────────────────────────────────────
@@ -448,17 +498,18 @@ interface TweenCallInfo {
448498 * True when the member chain of `callNode.callee` is rooted at the timeline
449499 * variable — `tl.to(...)` and every link of a chain `tl.to(...).to(...)`.
450500 */
451- function isTimelineRootedCall ( callNode : AstNode , timelineVar : string ) : boolean {
501+ function isTimelineRootedCall ( callNode : AstNode , ref : TimelineRef ) : boolean {
452502 let obj = callNode . callee ?. object ;
453503 while ( obj ?. type === "CallExpression" ) {
454504 obj = obj . callee ?. object ;
455505 }
456- return obj ?. type === "Identifier" && obj . name === timelineVar ;
506+ if ( ref . kind === "identifier" ) return obj ?. type === "Identifier" && obj . name === ref . name ;
507+ return sameMemberAccess ( obj , ref . node ) ;
457508}
458509
459510function findAllTweenCalls (
460511 ast : AstNode ,
461- timelineVar : string ,
512+ ref : TimelineRef ,
462513 scope : ScopeBindings ,
463514 targetBindings : TargetBindings ,
464515) : TweenCallInfo [ ] {
@@ -484,7 +535,7 @@ function findAllTweenCalls(
484535 if (
485536 callee ?. type === "MemberExpression" &&
486537 callee . property ?. type === "Identifier" &&
487- ( isTimelineRootedCall ( node , timelineVar ) || isGlobalSet )
538+ ( isTimelineRootedCall ( node , ref ) || isGlobalSet )
488539 ) {
489540 const method = callee . property . name ;
490541 if ( ! GSAP_METHODS . has ( method ) ) {
@@ -1131,8 +1182,9 @@ function parseGsapAst(script: string): ParsedGsapAst {
11311182 const scope = collectScopeBindings ( ast ) ;
11321183 const targetBindings = collectTargetBindings ( ast , scope ) ;
11331184 const detection = findTimelineVar ( ast , scope ) ;
1134- const timelineVar = detection . timelineVar ?? "tl" ;
1135- const calls = findAllTweenCalls ( ast , timelineVar , scope , targetBindings ) ;
1185+ const ref : TimelineRef = detection . ref ?? { kind : "identifier" , name : "tl" } ;
1186+ const timelineVar = timelineRootSource ( ref ) ;
1187+ const calls = findAllTweenCalls ( ast , ref , scope , targetBindings ) ;
11361188 sortBySourcePosition ( calls ) ;
11371189 const rawAnims = calls . map ( ( call ) => tweenCallToAnimation ( call , scope ) ) ;
11381190 applyTimelineDefaults ( rawAnims , detection . defaults ) ;
@@ -1151,15 +1203,19 @@ function parseGsapAst(script: string): ParsedGsapAst {
11511203export function parseGsapScript ( script : string ) : ParsedGsap {
11521204 try {
11531205 const { detection, timelineVar, located } = parseGsapAst ( script ) ;
1206+ const ref : TimelineRef = detection . ref ?? { kind : "identifier" , name : "tl" } ;
11541207 const animations = located . map ( ( l ) => l . animation ) ;
11551208
1156- const timelineMatch = script . match (
1157- new RegExp (
1158- `^[\\s\\S]*?(?:const|let|var)\\s+${ timelineVar } \\s*=\\s*gsap\\.timeline\\s*\\([^)]*\\)\\s*;?` ,
1159- ) ,
1160- ) ;
1161- const preamble =
1162- timelineMatch ?. [ 0 ] ?? `const ${ timelineVar } = gsap.timeline({ paused: true });` ;
1209+ const declPattern =
1210+ ref . kind === "identifier"
1211+ ? `(?:const|let|var)\\s+${ timelineVar } \\s*=\\s*gsap\\.timeline\\s*\\([^)]*\\)\\s*;?`
1212+ : `${ escapeRegExp ( timelineVar ) } \\s*=\\s*gsap\\.timeline\\s*\\([^)]*\\)\\s*;?` ;
1213+ const timelineMatch = script . match ( new RegExp ( `^[\\s\\S]*?${ declPattern } ` ) ) ;
1214+ const fallbackPreamble =
1215+ ref . kind === "identifier"
1216+ ? `const ${ timelineVar } = gsap.timeline({ paused: true });`
1217+ : `${ timelineVar } = gsap.timeline({ paused: true });` ;
1218+ const preamble = timelineMatch ?. [ 0 ] ?? fallbackPreamble ;
11631219
11641220 const lastCallIdx = script . lastIndexOf ( `${ timelineVar } .` ) ;
11651221 let postamble = "" ;
@@ -1173,7 +1229,7 @@ export function parseGsapScript(script: string): ParsedGsap {
11731229
11741230 const result : ParsedGsap = { animations, timelineVar, preamble, postamble } ;
11751231 if ( detection . timelineCount > 1 ) result . multipleTimelines = true ;
1176- if ( detection . timelineCount > 0 && detection . timelineVar === null )
1232+ if ( detection . timelineCount > 0 && detection . ref === null )
11771233 result . unsupportedTimelinePattern = true ;
11781234 return result ;
11791235 } catch {
@@ -1468,7 +1524,7 @@ export function addAnimationToScript(
14681524 return { script, id : "" } ;
14691525 }
14701526 // Nothing to anchor against and no timeline to target — treat as parse failure.
1471- if ( parsed . located . length === 0 && parsed . detection . timelineVar === null ) {
1527+ if ( parsed . located . length === 0 && parsed . detection . ref === null ) {
14721528 return { script, id : "" } ;
14731529 }
14741530
@@ -1500,7 +1556,7 @@ export function addAnimationWithKeyframesToScript(
15001556 console . warn ( "[gsap-parser] addAnimationWithKeyframesToScript parse failed:" , e ) ;
15011557 return { script, id : "" } ;
15021558 }
1503- if ( parsed . located . length === 0 && parsed . detection . timelineVar === null ) {
1559+ if ( parsed . located . length === 0 && parsed . detection . ref === null ) {
15041560 return { script, id : "" } ;
15051561 }
15061562
@@ -2796,7 +2852,7 @@ export function addMotionPathToScript(
27962852 console . warn ( "[gsap-parser] addMotionPathToScript parse failed:" , e ) ;
27972853 return { script, id : null } ;
27982854 }
2799- if ( parsed . located . length === 0 && parsed . detection . timelineVar === null ) {
2855+ if ( parsed . located . length === 0 && parsed . detection . ref === null ) {
28002856 return { script, id : null } ;
28012857 }
28022858
0 commit comments