feat(sdk,core): phase 3b — 8 gsap/label ops + setClassStyle (#1379)

This commit is contained in:
Vance Ingalls
2026-06-15 00:46:17 -07:00
committed by GitHub
parent 8b56e558c6
commit 6dcbb5530e
17 changed files with 1442 additions and 61 deletions
@@ -1,4 +1,4 @@
// fallow-ignore-file duplication
// fallow-ignore-file code-duplication
/**
* T6b — acorn vs golden differential harness.
*
@@ -1,4 +1,4 @@
// fallow-ignore-file duplication
// fallow-ignore-file code-duplication
/**
* T6d: parse-parity suite — runs the full gsapParser.test.ts parse scenarios
* against parseGsapScriptAcorn. Write-path tests are it.skip'd; those live
@@ -912,3 +912,140 @@ describe("native GSAP keyframes parsing", () => {
expect(Object.keys(anim.properties)).toHaveLength(0);
});
});
// ── motionPath parsing ────────────────────────────────────────────────────────
describe("motionPath parsing", () => {
it("parses motionPath with waypoint array and curviness", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el", {
motionPath: {
path: [{x: 0, y: 0}, {x: 200, y: -100}, {x: 400, y: 50}],
curviness: 1.5
},
duration: 2
}, 0);
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(1);
const anim = result.animations[0];
expect(anim.arcPath).toBeDefined();
expect(anim.arcPath!.enabled).toBe(true);
expect(anim.arcPath!.segments).toHaveLength(2);
expect(anim.arcPath!.segments[0].curviness).toBe(1.5);
expect(anim.arcPath!.segments[1].curviness).toBe(1.5);
expect(anim.keyframes).toBeDefined();
expect(anim.keyframes!.keyframes).toHaveLength(3);
expect(anim.keyframes!.keyframes[0].properties.x).toBe(0);
expect(anim.keyframes!.keyframes[0].properties.y).toBe(0);
expect(anim.keyframes!.keyframes[1].properties.x).toBe(200);
expect(anim.keyframes!.keyframes[1].properties.y).toBe(-100);
expect(anim.keyframes!.keyframes[2].properties.x).toBe(400);
expect(anim.keyframes!.keyframes[2].properties.y).toBe(50);
});
it("parses motionPath with type cubic and explicit control points", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el", {
motionPath: {
path: [
{x: 0, y: 0},
{x: 50, y: -80}, {x: 150, y: -120},
{x: 200, y: -100},
{x: 250, y: -80}, {x: 350, y: 30},
{x: 400, y: 50}
],
type: "cubic"
},
duration: 2
}, 0);
`;
const result = parseGsapScript(script);
const anim = result.animations[0];
expect(anim.arcPath).toBeDefined();
expect(anim.arcPath!.segments).toHaveLength(2);
expect(anim.arcPath!.segments[0].cp1).toEqual({ x: 50, y: -80 });
expect(anim.arcPath!.segments[0].cp2).toEqual({ x: 150, y: -120 });
expect(anim.arcPath!.segments[1].cp1).toEqual({ x: 250, y: -80 });
expect(anim.arcPath!.segments[1].cp2).toEqual({ x: 350, y: 30 });
expect(anim.keyframes!.keyframes).toHaveLength(3);
expect(anim.keyframes!.keyframes[0].properties).toEqual({ x: 0, y: 0 });
expect(anim.keyframes!.keyframes[1].properties).toEqual({ x: 200, y: -100 });
expect(anim.keyframes!.keyframes[2].properties).toEqual({ x: 400, y: 50 });
});
it("parses motionPath with autoRotate", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el", {
motionPath: {
path: [{x: 0, y: 0}, {x: 200, y: 100}],
autoRotate: true
},
duration: 1
}, 0);
`;
const result = parseGsapScript(script);
const anim = result.animations[0];
expect(anim.arcPath!.autoRotate).toBe(true);
});
it("merges motionPath waypoints into existing keyframes", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el", {
motionPath: {
path: [{x: 0, y: 0}, {x: 200, y: 100}],
curviness: 2
},
keyframes: {
"0%": { opacity: 1 },
"100%": { opacity: 0 }
},
duration: 2
}, 0);
`;
const result = parseGsapScript(script);
const anim = result.animations[0];
expect(anim.arcPath).toBeDefined();
expect(anim.arcPath!.segments).toHaveLength(1);
expect(anim.arcPath!.segments[0].curviness).toBe(2);
expect(anim.keyframes!.keyframes).toHaveLength(2);
expect(anim.keyframes!.keyframes[0].properties).toEqual({ opacity: 1, x: 0, y: 0 });
expect(anim.keyframes!.keyframes[1].properties).toEqual({ opacity: 0, x: 200, y: 100 });
});
it("skips motionPath with fewer than 2 waypoints", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el", {
motionPath: { path: [{x: 0, y: 0}] },
duration: 1
}, 0);
`;
const result = parseGsapScript(script);
expect(result.animations[0].arcPath).toBeUndefined();
});
it("tween without motionPath parses identically to before", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el", { x: 100, y: 200, duration: 1 }, 0);
`;
const result = parseGsapScript(script);
const anim = result.animations[0];
expect(anim.arcPath).toBeUndefined();
expect(anim.properties.x).toBe(100);
expect(anim.properties.y).toBe(200);
});
});
+3 -2
View File
@@ -1,4 +1,4 @@
// fallow-ignore-file duplication
// fallow-ignore-file code-duplication
/**
* Browser-safe GSAP read path — acorn + acorn-walk.
*
@@ -1046,6 +1046,7 @@ function assignStableIds(anims: Omit<GsapAnimation, "id">[]): GsapAnimation[] {
export interface ParsedGsapAcornForWrite {
ast: any;
timelineVar: string;
hasTimeline: boolean;
located: Array<{ id: string; call: TweenCallInfo; animation: GsapAnimation }>;
}
@@ -1075,7 +1076,7 @@ export function parseGsapScriptAcornForWrite(script: string): ParsedGsapAcornFor
call,
animation: animations[i]!,
}));
return { ast, timelineVar, located };
return { ast, timelineVar, hasTimeline: detection.timelineVar !== null, located };
} catch {
return null;
}
+2 -1
View File
@@ -91,6 +91,7 @@ export function serializeGsapAnimations(
b.resolvedStart ?? (typeof b.position === "number" ? b.position : Number.MAX_SAFE_INTEGER);
return aNum - bNum;
});
// fallow-ignore-next-line complexity
const lines = sorted.map((anim) => {
const selector = `"${anim.targetSelector}"`;
const props: Record<string, number | string> = { ...anim.properties };
@@ -200,7 +201,6 @@ export function getAnimationsForElementId(
const FORBIDDEN_GSAP_PATTERNS: Array<{ pattern: RegExp; message: string }> = [
{ pattern: /\.call\s*\(/, message: "call() method not allowed" },
{ pattern: /\.add\s*\(/, message: "add() method not allowed" },
{ pattern: /\.addLabel\s*\(/, message: "addLabel() method not allowed" },
{ pattern: /\.addPause\s*\(/, message: "addPause() method not allowed" },
{ pattern: /gsap\.registerEffect\s*\(/, message: "registerEffect() not allowed" },
{ pattern: /ScrollTrigger/, message: "ScrollTrigger not allowed" },
@@ -247,6 +247,7 @@ export function keyframesToGsapAnimations(
const baseY = base?.y ?? 0;
const baseScale = base?.scale ?? 1;
// fallow-ignore-next-line complexity
sorted.forEach((kf, i) => {
const absoluteTime = elementStartTime + kf.time;
const isFirst = i === 0;
@@ -1,4 +1,4 @@
// fallow-ignore-file duplication
// fallow-ignore-file code-duplication
/**
* T6c — acorn write path with magic-string offset-splice.
*
+93 -17
View File
@@ -1,4 +1,4 @@
// fallow-ignore-file duplication
// fallow-ignore-file code-duplication
/**
* Browser-safe GSAP write path — magic-string offset-splice.
*
@@ -8,15 +8,20 @@
*/
import MagicString from "magic-string";
import type { GsapAnimation } from "./gsapSerialize.js";
import { parseGsapScriptAcornForWrite, type TweenCallInfo } from "./gsapParserAcorn.js";
import {
parseGsapScriptAcornForWrite,
type ParsedGsapAcornForWrite,
type TweenCallInfo,
} from "./gsapParserAcorn.js";
import * as acornWalk from "acorn-walk";
// ── Code generation helpers ──────────────────────────────────────────────────
function valueToCode(value: number | string): string {
function valueToCode(value: unknown): string {
if (typeof value === "string" && value.startsWith("__raw:")) return value.slice(6);
if (typeof value === "string") return JSON.stringify(value);
return String(value);
if (typeof value === "number" || typeof value === "boolean") return String(value);
return JSON.stringify(value);
}
function safeKey(key: string): string {
@@ -32,7 +37,7 @@ function buildTweenStatementCode(timelineVar: string, anim: Omit<GsapAnimation,
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)}`);
entries.push(`${safeKey(k)}: ${valueToCode(v)}`);
}
}
const objCode = `{ ${entries.join(", ")} }`;
@@ -121,7 +126,7 @@ function removeProp(ms: MagicString, propNode: any, editableProps: any[]): void
* 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 {
function upsertProp(ms: MagicString, objNode: any, key: string, value: unknown): void {
if (objNode?.type !== "ObjectExpression") return;
const existing = findPropertyNode(objNode, key);
if (existing) {
@@ -132,6 +137,31 @@ function upsertProp(ms: MagicString, objNode: any, key: string, value: number |
}
}
// ── Insertion helpers ─────────────────────────────────────────────────────────
/** Traverse callee.object chain to check if a call ultimately roots at timelineVar. */
function isTimelineRooted(node: any, timelineVar: string): boolean {
if (node?.type === "Identifier") return node.name === timelineVar;
if (node?.type === "CallExpression") return isTimelineRooted(node.callee?.object, timelineVar);
return false;
}
/**
* Find the byte offset after which to insert a new statement (tween or label).
* Returns null when no timeline declaration exists in the script — callers must
* not emit `tl.xxx()` calls in that case as `tl` would be undefined at render.
*/
function findInsertionPoint(parsed: ParsedGsapAcornForWrite): number | null {
if (parsed.located.length > 0) {
const lastCall = parsed.located[parsed.located.length - 1]!.call;
const exprStmt = findEnclosingExpressionStatement(lastCall.ancestors);
return exprStmt?.end ?? lastCall.node.end;
}
if (!parsed.hasTimeline) return null;
const tlDecl = findTimelineDeclarationStatement(parsed.ast, parsed.timelineVar);
return tlDecl?.end ?? (parsed.ast.end as number);
}
// ── Public write API ─────────────────────────────────────────────────────────
// fallow-ignore-next-line complexity
@@ -179,6 +209,12 @@ export function updateAnimationInScript(
}
}
if (updates.extras) {
for (const [key, value] of Object.entries(updates.extras)) {
upsertProp(ms, call.varsArg, key, value);
}
}
return ms.toString();
}
@@ -189,19 +225,11 @@ export function addAnimationToScript(
const parsed = parseGsapScriptAcornForWrite(script);
if (!parsed) return { script, id: "" };
const insertionPoint = findInsertionPoint(parsed);
if (insertionPoint === null) 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();
@@ -366,3 +394,51 @@ export function removeKeyframeFromScript(
removeProp(ms, match.prop, allProps);
return ms.toString();
}
// ── Label write ops ───────────────────────────────────────────────────────────
export function addLabelToScript(script: string, name: string, position: number): string {
const parsed = parseGsapScriptAcornForWrite(script);
if (!parsed) return script;
const insertionPoint = findInsertionPoint(parsed);
if (insertionPoint === null) return script;
const ms = new MagicString(script);
const labelCode = `${parsed.timelineVar}.addLabel(${JSON.stringify(name)}, ${valueToCode(position)});`;
ms.appendLeft(insertionPoint, "\n" + labelCode);
return ms.toString();
}
export function removeLabelFromScript(script: string, name: string): string {
const parsed = parseGsapScriptAcornForWrite(script);
if (!parsed) return script;
const targets: any[] = [];
acornWalk.simple(parsed.ast, {
// fallow-ignore-next-line complexity
ExpressionStatement(node: any) {
const expr = node.expression;
if (
expr?.type === "CallExpression" &&
expr.callee?.type === "MemberExpression" &&
isTimelineRooted(expr.callee.object, parsed.timelineVar) &&
expr.callee.property?.name === "addLabel" &&
expr.arguments?.[0]?.type === "Literal" &&
expr.arguments[0].value === name
) {
targets.push(node);
}
},
});
if (!targets.length) return script;
const ms = new MagicString(script);
for (const target of targets) {
const end =
target.end < script.length && script[target.end] === "\n" ? target.end + 1 : target.end;
ms.remove(target.start, end);
}
return ms.toString();
}