Skip to content

Commit 533a3b0

Browse files
committed
feat(sdk,core): phase 3b — 8 gsap/label ops + setClassStyle
1 parent ef2dc8e commit 533a3b0

17 files changed

Lines changed: 1339 additions & 93 deletions

packages/core/package.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,14 @@
7474
"import": "./src/parsers/gsapParser.ts",
7575
"types": "./src/parsers/gsapParser.ts"
7676
},
77+
"./gsap-parser-acorn": {
78+
"import": "./src/parsers/gsapParserAcorn.ts",
79+
"types": "./src/parsers/gsapParserAcorn.ts"
80+
},
81+
"./gsap-writer-acorn": {
82+
"import": "./src/parsers/gsapWriterAcorn.ts",
83+
"types": "./src/parsers/gsapWriterAcorn.ts"
84+
},
7785
"./gsap-constants": {
7886
"import": "./src/parsers/gsapConstants.ts",
7987
"types": "./src/parsers/gsapConstants.ts"
@@ -153,6 +161,14 @@
153161
"import": "./dist/parsers/gsapParser.js",
154162
"types": "./dist/parsers/gsapParser.d.ts"
155163
},
164+
"./gsap-parser-acorn": {
165+
"import": "./dist/parsers/gsapParserAcorn.js",
166+
"types": "./dist/parsers/gsapParserAcorn.d.ts"
167+
},
168+
"./gsap-writer-acorn": {
169+
"import": "./dist/parsers/gsapWriterAcorn.js",
170+
"types": "./dist/parsers/gsapWriterAcorn.d.ts"
171+
},
156172
"./gsap-constants": {
157173
"import": "./dist/parsers/gsapConstants.js",
158174
"types": "./dist/parsers/gsapConstants.d.ts"

packages/core/src/parsers/gsapParser.acorn.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// fallow-ignore-file duplication
1+
// fallow-ignore-file code-duplication
22
/**
33
* T6b — acorn vs golden differential harness.
44
*

packages/core/src/parsers/gsapParserAcorn.full.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// fallow-ignore-file duplication
1+
// fallow-ignore-file code-duplication
22
/**
33
* T6d: parse-parity suite — runs the full gsapParser.test.ts parse scenarios
44
* against parseGsapScriptAcorn. Write-path tests are it.skip'd; those live

packages/core/src/parsers/gsapParserAcorn.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// fallow-ignore-file duplication
1+
// fallow-ignore-file code-duplication
22
/**
33
* Browser-safe GSAP read path — acorn + acorn-walk.
44
*
@@ -1046,6 +1046,7 @@ function assignStableIds(anims: Omit<GsapAnimation, "id">[]): GsapAnimation[] {
10461046
export interface ParsedGsapAcornForWrite {
10471047
ast: any;
10481048
timelineVar: string;
1049+
hasTimeline: boolean;
10491050
located: Array<{ id: string; call: TweenCallInfo; animation: GsapAnimation }>;
10501051
}
10511052

@@ -1075,7 +1076,7 @@ export function parseGsapScriptAcornForWrite(script: string): ParsedGsapAcornFor
10751076
call,
10761077
animation: animations[i]!,
10771078
}));
1078-
return { ast, timelineVar, located };
1079+
return { ast, timelineVar, hasTimeline: detection.timelineVar !== null, located };
10791080
} catch {
10801081
return null;
10811082
}

packages/core/src/parsers/gsapSerialize.ts

Lines changed: 39 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ export function serializeGsapAnimations(
9191
b.resolvedStart ?? (typeof b.position === "number" ? b.position : Number.MAX_SAFE_INTEGER);
9292
return aNum - bNum;
9393
});
94+
// fallow-ignore-next-line complexity
9495
const lines = sorted.map((anim) => {
9596
const selector = `"${anim.targetSelector}"`;
9697
const props: Record<string, number | string> = { ...anim.properties };
@@ -198,7 +199,6 @@ export function getAnimationsForElementId(
198199
const FORBIDDEN_GSAP_PATTERNS: Array<{ pattern: RegExp; message: string }> = [
199200
{ pattern: /\.call\s*\(/, message: "call() method not allowed" },
200201
{ pattern: /\.add\s*\(/, message: "add() method not allowed" },
201-
{ pattern: /\.addLabel\s*\(/, message: "addLabel() method not allowed" },
202202
{ pattern: /\.addPause\s*\(/, message: "addPause() method not allowed" },
203203
{ pattern: /gsap\.registerEffect\s*\(/, message: "registerEffect() not allowed" },
204204
{ pattern: /ScrollTrigger/, message: "ScrollTrigger not allowed" },
@@ -245,6 +245,7 @@ export function keyframesToGsapAnimations(
245245
const baseY = base?.y ?? 0;
246246
const baseScale = base?.scale ?? 1;
247247

248+
// fallow-ignore-next-line complexity
248249
sorted.forEach((kf, i) => {
249250
const absoluteTime = elementStartTime + kf.time;
250251
const isFirst = i === 0;
@@ -295,41 +296,44 @@ export function gsapAnimationsToKeyframes(
295296
const baseTimeEpsilon = 0.001;
296297
const baseValueEpsilon = 0.00001;
297298

298-
return animations
299-
.filter((a) => validMethods.includes(a.method) && typeof a.position === "number")
300-
.map((a) => {
301-
const relativeTimeRaw = (a.position as number) - elementStartTime;
302-
const time = clampTimeToZero ? Math.max(0, relativeTimeRaw) : relativeTimeRaw;
303-
304-
const properties: Partial<KeyframeProperties> = {};
305-
for (const [key, value] of Object.entries(a.properties)) {
306-
if (typeof value !== "number") continue;
307-
if (key === "x") properties.x = value - baseX;
308-
else if (key === "y") properties.y = value - baseY;
309-
else if (key === "scale") {
310-
properties.scale = baseScale !== 0 ? value / baseScale : value;
311-
} else {
312-
(properties as Record<string, number>)[key] = value;
299+
return (
300+
animations
301+
.filter((a) => validMethods.includes(a.method) && typeof a.position === "number")
302+
// fallow-ignore-next-line complexity
303+
.map((a) => {
304+
const relativeTimeRaw = (a.position as number) - elementStartTime;
305+
const time = clampTimeToZero ? Math.max(0, relativeTimeRaw) : relativeTimeRaw;
306+
307+
const properties: Partial<KeyframeProperties> = {};
308+
for (const [key, value] of Object.entries(a.properties)) {
309+
if (typeof value !== "number") continue;
310+
if (key === "x") properties.x = value - baseX;
311+
else if (key === "y") properties.y = value - baseY;
312+
else if (key === "scale") {
313+
properties.scale = baseScale !== 0 ? value / baseScale : value;
314+
} else {
315+
(properties as Record<string, number>)[key] = value;
316+
}
313317
}
314-
}
315318

316-
if (
317-
skipBaseSet &&
318-
a.method === "set" &&
319-
time < baseTimeEpsilon &&
320-
Object.values(properties).every(
321-
(v) => typeof v === "number" && Math.abs(v) < baseValueEpsilon,
322-
)
323-
) {
324-
return null;
325-
}
319+
if (
320+
skipBaseSet &&
321+
a.method === "set" &&
322+
time < baseTimeEpsilon &&
323+
Object.values(properties).every(
324+
(v) => typeof v === "number" && Math.abs(v) < baseValueEpsilon,
325+
)
326+
) {
327+
return null;
328+
}
326329

327-
return {
328-
id: a.id.replace(/^.*-kf-/, ""),
329-
time,
330-
properties: properties as KeyframeProperties,
331-
ease: a.ease,
332-
};
333-
})
334-
.filter((kf): kf is NonNullable<typeof kf> => kf !== null) as Keyframe[];
330+
return {
331+
id: a.id.replace(/^.*-kf-/, ""),
332+
time,
333+
properties: properties as KeyframeProperties,
334+
ease: a.ease,
335+
};
336+
})
337+
.filter((kf): kf is NonNullable<typeof kf> => kf !== null) as Keyframe[]
338+
);
335339
}

packages/core/src/parsers/gsapWriter.acorn.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// fallow-ignore-file duplication
1+
// fallow-ignore-file code-duplication
22
/**
33
* T6c — acorn write path with magic-string offset-splice.
44
*

packages/core/src/parsers/gsapWriterAcorn.ts

Lines changed: 93 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// fallow-ignore-file duplication
1+
// fallow-ignore-file code-duplication
22
/**
33
* Browser-safe GSAP write path — magic-string offset-splice.
44
*
@@ -8,15 +8,20 @@
88
*/
99
import MagicString from "magic-string";
1010
import type { GsapAnimation } from "./gsapSerialize.js";
11-
import { parseGsapScriptAcornForWrite, type TweenCallInfo } from "./gsapParserAcorn.js";
11+
import {
12+
parseGsapScriptAcornForWrite,
13+
type ParsedGsapAcornForWrite,
14+
type TweenCallInfo,
15+
} from "./gsapParserAcorn.js";
1216
import * as acornWalk from "acorn-walk";
1317

1418
// ── Code generation helpers ──────────────────────────────────────────────────
1519

16-
function valueToCode(value: number | string): string {
20+
function valueToCode(value: unknown): string {
1721
if (typeof value === "string" && value.startsWith("__raw:")) return value.slice(6);
1822
if (typeof value === "string") return JSON.stringify(value);
19-
return String(value);
23+
if (typeof value === "number" || typeof value === "boolean") return String(value);
24+
return JSON.stringify(value);
2025
}
2126

2227
function safeKey(key: string): string {
@@ -32,7 +37,7 @@ function buildTweenStatementCode(timelineVar: string, anim: Omit<GsapAnimation,
3237
const entries = Object.entries(props).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`);
3338
if (anim.extras) {
3439
for (const [k, v] of Object.entries(anim.extras)) {
35-
entries.push(`${safeKey(k)}: ${valueToCode(v as number | string)}`);
40+
entries.push(`${safeKey(k)}: ${valueToCode(v)}`);
3641
}
3742
}
3843
const objCode = `{ ${entries.join(", ")} }`;
@@ -121,7 +126,7 @@ function removeProp(ms: MagicString, propNode: any, editableProps: any[]): void
121126
* Update a property value if it exists, or append a new key: val before the
122127
* closing `}`. Call with the full ObjectExpression node.
123128
*/
124-
function upsertProp(ms: MagicString, objNode: any, key: string, value: number | string): void {
129+
function upsertProp(ms: MagicString, objNode: any, key: string, value: unknown): void {
125130
if (objNode?.type !== "ObjectExpression") return;
126131
const existing = findPropertyNode(objNode, key);
127132
if (existing) {
@@ -132,6 +137,31 @@ function upsertProp(ms: MagicString, objNode: any, key: string, value: number |
132137
}
133138
}
134139

140+
// ── Insertion helpers ─────────────────────────────────────────────────────────
141+
142+
/** Traverse callee.object chain to check if a call ultimately roots at timelineVar. */
143+
function isTimelineRooted(node: any, timelineVar: string): boolean {
144+
if (node?.type === "Identifier") return node.name === timelineVar;
145+
if (node?.type === "CallExpression") return isTimelineRooted(node.callee?.object, timelineVar);
146+
return false;
147+
}
148+
149+
/**
150+
* Find the byte offset after which to insert a new statement (tween or label).
151+
* Returns null when no timeline declaration exists in the script — callers must
152+
* not emit `tl.xxx()` calls in that case as `tl` would be undefined at render.
153+
*/
154+
function findInsertionPoint(parsed: ParsedGsapAcornForWrite): number | null {
155+
if (parsed.located.length > 0) {
156+
const lastCall = parsed.located[parsed.located.length - 1]!.call;
157+
const exprStmt = findEnclosingExpressionStatement(lastCall.ancestors);
158+
return exprStmt?.end ?? lastCall.node.end;
159+
}
160+
if (!parsed.hasTimeline) return null;
161+
const tlDecl = findTimelineDeclarationStatement(parsed.ast, parsed.timelineVar);
162+
return tlDecl?.end ?? (parsed.ast.end as number);
163+
}
164+
135165
// ── Public write API ─────────────────────────────────────────────────────────
136166

137167
// fallow-ignore-next-line complexity
@@ -179,6 +209,12 @@ export function updateAnimationInScript(
179209
}
180210
}
181211

212+
if (updates.extras) {
213+
for (const [key, value] of Object.entries(updates.extras)) {
214+
upsertProp(ms, call.varsArg, key, value);
215+
}
216+
}
217+
182218
return ms.toString();
183219
}
184220

@@ -189,19 +225,11 @@ export function addAnimationToScript(
189225
const parsed = parseGsapScriptAcornForWrite(script);
190226
if (!parsed) return { script, id: "" };
191227

228+
const insertionPoint = findInsertionPoint(parsed);
229+
if (insertionPoint === null) return { script, id: "" };
230+
192231
const ms = new MagicString(script);
193232
const statementCode = buildTweenStatementCode(parsed.timelineVar, animation);
194-
195-
let insertionPoint: number;
196-
if (parsed.located.length > 0) {
197-
const lastCall = parsed.located[parsed.located.length - 1]!.call;
198-
const exprStmt = findEnclosingExpressionStatement(lastCall.ancestors);
199-
insertionPoint = exprStmt?.end ?? lastCall.node.end;
200-
} else {
201-
const tlDecl = findTimelineDeclarationStatement(parsed.ast, parsed.timelineVar);
202-
insertionPoint = tlDecl?.end ?? script.length;
203-
}
204-
205233
ms.appendLeft(insertionPoint, "\n" + statementCode);
206234

207235
const result = ms.toString();
@@ -366,3 +394,51 @@ export function removeKeyframeFromScript(
366394
removeProp(ms, match.prop, allProps);
367395
return ms.toString();
368396
}
397+
398+
// ── Label write ops ───────────────────────────────────────────────────────────
399+
400+
export function addLabelToScript(script: string, name: string, position: number): string {
401+
const parsed = parseGsapScriptAcornForWrite(script);
402+
if (!parsed) return script;
403+
404+
const insertionPoint = findInsertionPoint(parsed);
405+
if (insertionPoint === null) return script;
406+
407+
const ms = new MagicString(script);
408+
const labelCode = `${parsed.timelineVar}.addLabel(${JSON.stringify(name)}, ${valueToCode(position)});`;
409+
ms.appendLeft(insertionPoint, "\n" + labelCode);
410+
return ms.toString();
411+
}
412+
413+
export function removeLabelFromScript(script: string, name: string): string {
414+
const parsed = parseGsapScriptAcornForWrite(script);
415+
if (!parsed) return script;
416+
417+
const targets: any[] = [];
418+
acornWalk.simple(parsed.ast, {
419+
// fallow-ignore-next-line complexity
420+
ExpressionStatement(node: any) {
421+
const expr = node.expression;
422+
if (
423+
expr?.type === "CallExpression" &&
424+
expr.callee?.type === "MemberExpression" &&
425+
isTimelineRooted(expr.callee.object, parsed.timelineVar) &&
426+
expr.callee.property?.name === "addLabel" &&
427+
expr.arguments?.[0]?.type === "Literal" &&
428+
expr.arguments[0].value === name
429+
) {
430+
targets.push(node);
431+
}
432+
},
433+
});
434+
435+
if (!targets.length) return script;
436+
437+
const ms = new MagicString(script);
438+
for (const target of targets) {
439+
const end =
440+
target.end < script.length && script[target.end] === "\n" ? target.end + 1 : target.end;
441+
ms.remove(target.start, end);
442+
}
443+
return ms.toString();
444+
}

0 commit comments

Comments
 (0)