From 772080c3237ab30ced7b139658d2d852ab8ad750 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 26 Jun 2026 17:49:19 -0400 Subject: [PATCH 01/11] refactor: scaffold @hyperframes/parsers package --- packages/parsers/package.json | 97 +++++++++++++++++++++++++++++++ packages/parsers/src/index.ts | 2 + packages/parsers/tsconfig.json | 18 ++++++ packages/parsers/tsup.config.ts | 22 +++++++ packages/parsers/vitest.config.ts | 8 +++ 5 files changed, 147 insertions(+) create mode 100644 packages/parsers/package.json create mode 100644 packages/parsers/src/index.ts create mode 100644 packages/parsers/tsconfig.json create mode 100644 packages/parsers/tsup.config.ts create mode 100644 packages/parsers/vitest.config.ts diff --git a/packages/parsers/package.json b/packages/parsers/package.json new file mode 100644 index 0000000000..3fbef63495 --- /dev/null +++ b/packages/parsers/package.json @@ -0,0 +1,97 @@ +{ + "name": "@hyperframes/parsers", + "version": "0.7.11", + "repository": { + "type": "git", + "url": "https://github.com/heygen-com/hyperframes", + "directory": "packages/parsers" + }, + "files": [ + "dist", + "README.md" + ], + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "import": "./src/index.ts", + "types": "./src/index.ts" + }, + "./package.json": "./package.json", + "./gsap-parser": { + "import": "./src/gsapParserExports.ts", + "types": "./src/gsapParserExports.ts" + }, + "./gsap-parser-acorn": { + "import": "./src/gsapParserAcorn.ts", + "types": "./src/gsapParserAcorn.ts" + }, + "./gsap-writer-acorn": { + "import": "./src/gsapWriterAcorn.ts", + "types": "./src/gsapWriterAcorn.ts" + }, + "./gsap-constants": { + "import": "./src/gsapConstants.ts", + "types": "./src/gsapConstants.ts" + }, + "./spring-ease": { + "import": "./src/springEase.ts", + "types": "./src/springEase.ts" + }, + "./hf-ids": { + "import": "./src/hfIds.ts", + "types": "./src/hfIds.ts" + } + }, + "publishConfig": { + "access": "public", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "./package.json": "./package.json", + "./gsap-parser": { + "import": "./dist/gsapParserExports.js", + "types": "./dist/gsapParserExports.d.ts" + }, + "./gsap-parser-acorn": { + "import": "./dist/gsapParserAcorn.js", + "types": "./dist/gsapParserAcorn.d.ts" + }, + "./gsap-writer-acorn": { + "import": "./dist/gsapWriterAcorn.js", + "types": "./dist/gsapWriterAcorn.d.ts" + }, + "./gsap-constants": { + "import": "./dist/gsapConstants.js", + "types": "./dist/gsapConstants.d.ts" + }, + "./spring-ease": { + "import": "./dist/springEase.js", + "types": "./dist/springEase.d.ts" + }, + "./hf-ids": { + "import": "./dist/hfIds.js", + "types": "./dist/hfIds.d.ts" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "scripts": { + "build": "tsup", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit", + "prepublishOnly": "echo skip" + }, + "devDependencies": { + "@types/node": "^25.0.10", + "tsup": "^8.0.0", + "tsx": "^4.21.0", + "typescript": "^5.0.0", + "vitest": "^3.2.4" + } +} diff --git a/packages/parsers/src/index.ts b/packages/parsers/src/index.ts new file mode 100644 index 0000000000..b5d8e0c4de --- /dev/null +++ b/packages/parsers/src/index.ts @@ -0,0 +1,2 @@ +// barrel — populated in U3 +export {}; diff --git a/packages/parsers/tsconfig.json b/packages/parsers/tsconfig.json new file mode 100644 index 0000000000..572403d6d7 --- /dev/null +++ b/packages/parsers/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noUncheckedIndexedAccess": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts", "src/test-utils.ts"] +} diff --git a/packages/parsers/tsup.config.ts b/packages/parsers/tsup.config.ts new file mode 100644 index 0000000000..3cae454c5a --- /dev/null +++ b/packages/parsers/tsup.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: { + index: "src/index.ts", + gsapParserExports: "src/gsapParserExports.ts", + gsapParserAcorn: "src/gsapParserAcorn.ts", + gsapWriterAcorn: "src/gsapWriterAcorn.ts", + gsapConstants: "src/gsapConstants.ts", + springEase: "src/springEase.ts", + hfIds: "src/hfIds.ts", + }, + format: ["esm"], + outDir: "dist", + target: "node22", + platform: "node", + bundle: true, + splitting: false, + sourcemap: true, + clean: true, + dts: true, +}); diff --git a/packages/parsers/vitest.config.ts b/packages/parsers/vitest.config.ts new file mode 100644 index 0000000000..dc1fee04c8 --- /dev/null +++ b/packages/parsers/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + environment: "jsdom", + }, +}); From 067fd09b33d7070553c6e383a90782169a1c0523 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 26 Jun 2026 18:00:42 -0400 Subject: [PATCH 02/11] refactor: move composition data types to @hyperframes/parsers --- bun.lock | 34 ++- packages/core/package.json | 1 + packages/core/src/core.types.ts | 509 +++----------------------------- packages/parsers/src/index.ts | 3 +- packages/parsers/src/types.ts | 467 +++++++++++++++++++++++++++++ 5 files changed, 540 insertions(+), 474 deletions(-) create mode 100644 packages/parsers/src/types.ts diff --git a/bun.lock b/bun.lock index 92e55ee580..b319935f11 100644 --- a/bun.lock +++ b/bun.lock @@ -22,7 +22,7 @@ }, "packages/aws-lambda": { "name": "@hyperframes/aws-lambda", - "version": "0.6.119", + "version": "0.7.11", "dependencies": { "@aws-sdk/client-s3": "^3.700.0", "@aws-sdk/client-sfn": "^3.700.0", @@ -54,7 +54,7 @@ }, "packages/cli": { "name": "@hyperframes/cli", - "version": "0.6.119", + "version": "0.7.11", "bin": { "hyperframes": "./dist/cli.js", }, @@ -101,10 +101,11 @@ }, "packages/core": { "name": "@hyperframes/core", - "version": "0.6.119", + "version": "0.7.11", "dependencies": { "@babel/parser": "^7.27.0", "@chenglou/pretext": "^0.0.5", + "@hyperframes/parsers": "workspace:*", "acorn": "^8.17.0", "acorn-walk": "^8.3.5", "bpm-detective": "^2.0.5", @@ -135,7 +136,7 @@ }, "packages/engine": { "name": "@hyperframes/engine", - "version": "0.6.119", + "version": "0.7.11", "dependencies": { "@hono/node-server": "^1.13.0", "@hyperframes/core": "workspace:^", @@ -153,7 +154,7 @@ }, "packages/gcp-cloud-run": { "name": "@hyperframes/gcp-cloud-run", - "version": "0.6.119", + "version": "0.7.11", "dependencies": { "@google-cloud/storage": "^7.14.0", "@google-cloud/workflows": "^4.2.0", @@ -171,9 +172,20 @@ "typescript": "^5.7.2", }, }, + "packages/parsers": { + "name": "@hyperframes/parsers", + "version": "0.7.11", + "devDependencies": { + "@types/node": "^25.0.10", + "tsup": "^8.0.0", + "tsx": "^4.21.0", + "typescript": "^5.0.0", + "vitest": "^3.2.4", + }, + }, "packages/player": { "name": "@hyperframes/player", - "version": "0.6.119", + "version": "0.7.11", "dependencies": { "@hyperframes/core": "workspace:*", }, @@ -188,7 +200,7 @@ }, "packages/producer": { "name": "@hyperframes/producer", - "version": "0.6.119", + "version": "0.7.11", "dependencies": { "@fontsource/archivo-black": "^5.2.8", "@fontsource/eb-garamond": "^5.2.7", @@ -229,7 +241,7 @@ }, "packages/sdk": { "name": "@hyperframes/sdk", - "version": "0.6.119", + "version": "0.7.11", "dependencies": { "@hyperframes/core": "workspace:*", "linkedom": "^0.18.12", @@ -254,7 +266,7 @@ }, "packages/shader-transitions": { "name": "@hyperframes/shader-transitions", - "version": "0.6.119", + "version": "0.7.11", "dependencies": { "html2canvas": "^1.4.1", }, @@ -266,7 +278,7 @@ }, "packages/studio": { "name": "@hyperframes/studio", - "version": "0.6.119", + "version": "0.7.11", "dependencies": { "@codemirror/autocomplete": "^6.20.1", "@codemirror/commands": "^6.10.3", @@ -667,6 +679,8 @@ "@hyperframes/gcp-cloud-run": ["@hyperframes/gcp-cloud-run@workspace:packages/gcp-cloud-run"], + "@hyperframes/parsers": ["@hyperframes/parsers@workspace:packages/parsers"], + "@hyperframes/player": ["@hyperframes/player@workspace:packages/player"], "@hyperframes/producer": ["@hyperframes/producer@workspace:packages/producer"], diff --git a/packages/core/package.json b/packages/core/package.json index 42b023e5ae..d500250e6b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -282,6 +282,7 @@ "dependencies": { "@babel/parser": "^7.27.0", "@chenglou/pretext": "^0.0.5", + "@hyperframes/parsers": "workspace:*", "acorn": "^8.17.0", "acorn-walk": "^8.3.5", "bpm-detective": "^2.0.5", diff --git a/packages/core/src/core.types.ts b/packages/core/src/core.types.ts index 42d4229e0b..dd3b90e716 100644 --- a/packages/core/src/core.types.ts +++ b/packages/core/src/core.types.ts @@ -139,466 +139,51 @@ export function parseFpsWithDefault(input: string | number | undefined): FpsPars /** Video orientation / aspect ratio. */ export type Orientation = "16:9" | "9:16"; -export interface Asset { - id: string; - url: string; - type: string; - is_reference?: boolean; - /** Duration in seconds for video/audio assets */ - duration?: number; -} - -// ── Timeline types ────────────────────────────────────────────────────────── - -export type TimelineElementType = "video" | "image" | "text" | "audio" | "composition"; -export type MediaElementType = "video" | "image" | "audio"; - -export const CANVAS_DIMENSIONS = { - landscape: { width: 1920, height: 1080 }, - portrait: { width: 1080, height: 1920 }, - "landscape-4k": { width: 3840, height: 2160 }, - "portrait-4k": { width: 2160, height: 3840 }, - square: { width: 1080, height: 1080 }, - "square-4k": { width: 2160, height: 2160 }, -} as const; - -// Single source of truth: derive the type from the table so adding a preset -// extends the union automatically. Avoids the prior `as readonly CanvasResolution[]` -// cast on `VALID_CANVAS_RESOLUTIONS` quietly drifting if the table grew but -// the union didn't. -export type CanvasResolution = keyof typeof CANVAS_DIMENSIONS; - -// `Object.keys` ordering matches insertion order in `CANVAS_DIMENSIONS` on -// every supported JS engine; tests pin the order in `index.test.ts`. Reorder -// the table above with care. -export const VALID_CANVAS_RESOLUTIONS = Object.keys( +// ── Re-exports from @hyperframes/parsers (moved in refactor) ───────────────── +// @deprecated — import from @hyperframes/parsers directly + +export type { + Asset, + TimelineElementType, + MediaElementType, + TimelineElementBase, + TimelineMediaElement, + WaveformData, + TimelineTextElement, + TimelineCompositionElement, + CompositionVariableType, + CompositionVariableBase, + StringVariable, + NumberVariable, + ColorVariable, + BooleanVariable, + EnumVariable, + CompositionVariable, + CompositionSpec, + TimelineElement, + MediaFile, + CanvasResolution, + CompositionAPI, + PlayerAPI, + AddElementData, + ValidationResult, + CompositionAsset, + Keyframe, + KeyframeProperties, + ElementKeyframes, + StageZoom, + StageZoomKeyframe, +} from "@hyperframes/parsers"; + +export { CANVAS_DIMENSIONS, -) as readonly CanvasResolution[]; - -const RESOLUTION_ALIASES: Record = { - "1080p": "landscape", - hd: "landscape", - "1080p-portrait": "portrait", - "portrait-1080p": "portrait", - "4k": "landscape-4k", - uhd: "landscape-4k", - "4k-portrait": "portrait-4k", - "1080p-square": "square", - "square-1080p": "square", - "4k-square": "square-4k", -}; - -/** - * Map a user-facing resolution string (canonical name or alias) to a - * `CanvasResolution`. Returns undefined for unknown values so callers - * can produce their own "invalid" UX (CLI exit, route validation, etc.). - */ -export function normalizeResolutionFlag(input: string | undefined): CanvasResolution | undefined { - if (!input) return undefined; - const lowered = input.toLowerCase(); - if ((VALID_CANVAS_RESOLUTIONS as readonly string[]).includes(lowered)) { - return lowered as CanvasResolution; - } - return RESOLUTION_ALIASES[lowered]; -} - -export interface TimelineElementBase { - id: string; - type: TimelineElementType; - name: string; - startTime: number; - duration: number; - zIndex: number; - x?: number; - y?: number; - scale?: number; - opacity?: number; -} - -export interface TimelineMediaElement extends TimelineElementBase { - type: MediaElementType; - src: string; - mediaStartTime?: number; - sourceDuration?: number; - isAroll?: boolean; - sourceWidth?: number; - sourceHeight?: number; - volume?: number; // 0-1 (0% to 100%), default 1.0 - hasAudio?: boolean; // For videos - indicates if video has audio track -} - -export interface WaveformData { - peaks: number[]; - duration: number; - sampleRate?: number; -} - -export interface TimelineTextElement extends TimelineElementBase { - type: "text"; - content: string; - color?: string; - fontSize?: number; - textShadow?: boolean; - fontFamily?: string; - fontWeight?: number; - textOutline?: boolean; - textOutlineColor?: string; - textOutlineWidth?: number; - textHighlight?: boolean; - textHighlightColor?: string; - textHighlightPadding?: number; - textHighlightRadius?: number; -} - -export interface TimelineCompositionElement extends TimelineElementBase { - type: "composition"; - src: string; - compositionId: string; - scale?: number; - sourceDuration?: number; - variableValues?: Record; - sourceWidth?: number; - sourceHeight?: number; -} - -// Composition Variable Types -export type CompositionVariableType = - | "string" - | "number" - | "color" - | "boolean" - | "enum" - | "font" - | "image"; - -/** - * Runtime list of every valid `CompositionVariableType`. Use this anywhere - * a Set/array of valid type strings is needed (lint rules, validators). - * The `satisfies` guard turns adding a new variant to the union without - * also adding it here into a compile error. - */ -export const COMPOSITION_VARIABLE_TYPES = [ - "string", - "number", - "color", - "boolean", - "enum", - "font", - "image", -] as const satisfies readonly CompositionVariableType[]; - -export interface CompositionVariableBase { - id: string; - type: CompositionVariableType; - label: string; - description?: string; -} - -export interface StringVariable extends CompositionVariableBase { - type: "string"; - default: string; - placeholder?: string; - maxLength?: number; -} - -export interface NumberVariable extends CompositionVariableBase { - type: "number"; - default: number; - min?: number; - max?: number; - step?: number; - unit?: string; -} - -export interface ColorVariable extends CompositionVariableBase { - type: "color"; - default: string; - /** Brand role identifier, e.g. "color:primary". */ - brandRole?: string; -} - -export interface BooleanVariable extends CompositionVariableBase { - type: "boolean"; - default: boolean; -} - -export interface EnumVariable extends CompositionVariableBase { - type: "enum"; - default: string; - options: { value: string; label: string }[]; -} - -/** - * Font variable — value is a `{name, source}` object (object-valued; LOCKED §7). - * `default` is the fallback font-family name string. - * `source` is the font stylesheet URL (e.g. Google Fonts CSS). - * `default_name` / `default_source` are the CSS-level fallbacks when the - * brand font is absent. - */ -export interface FontVariable extends CompositionVariableBase { - type: "font"; - /** Fallback font-family name, e.g. "Inter". */ - default: string; - /** Font stylesheet URL (e.g. Google Fonts CSS link). */ - source?: string; - /** CSS font-family name to use when source is unavailable, e.g. "sans-serif". */ - default_name?: string; - /** Fallback font stylesheet URL (empty string = system font). */ - default_source?: string; -} - -/** - * Image variable — value is a `{url, …}` object (object-valued; LOCKED §7). - * `default` is the fallback image URL string. - * `brandRole` is an optional semantic label, e.g. "logo:primary". - */ -export interface ImageVariable extends CompositionVariableBase { - type: "image"; - /** Fallback image URL. */ - default: string; - /** Brand role identifier, e.g. "logo:primary". */ - brandRole?: string; -} - -export type CompositionVariable = - | StringVariable - | NumberVariable - | ColorVariable - | BooleanVariable - | EnumVariable - | FontVariable - | ImageVariable; - -export interface CompositionSpec { - id: string; - duration: number; - variables: CompositionVariable[]; -} - -export type TimelineElement = - | TimelineMediaElement - | TimelineTextElement - | TimelineCompositionElement; - -export function isTextElement(el: TimelineElement): el is TimelineTextElement { - return el.type === "text"; -} - -export function isMediaElement(el: TimelineElement): el is TimelineMediaElement { - return el.type === "video" || el.type === "image" || el.type === "audio"; -} - -export function isCompositionElement(el: TimelineElement): el is TimelineCompositionElement { - return el.type === "composition"; -} - -export interface MediaFile { - id: string; - name: string; - type: TimelineElementType; - src: string; - file?: File; - duration?: number; - compositionId?: string; - sourceWidth?: number; // Intrinsic width for compositions - sourceHeight?: number; // Intrinsic height for compositions -} - -export const TIMELINE_COLORS: Record = { - video: "#ec4899", - image: "#3b82f6", - text: "#06b6d4", - audio: "#10b981", - composition: "#f97316", -}; - -export const DEFAULT_DURATIONS: Record = { - video: 5, - image: 5, - text: 2, - audio: 5, - composition: 5, -}; - -export interface CompositionAPI { - id: string; - duration: number; - seek(time: number): void; - getTime(): number; - getDuration(): number; -} - -// ── Player API types (used by runtime) ──────────────────────────────────── - -export interface PlayerAPI { - play(): void; - pause(): void; - seek(time: number, options?: { keepPlaying?: boolean }): void; - getTime(): number; - getDuration(): number; - isPlaying(): boolean; - getMainTimeline(): unknown; - getElementBounds(elementId: string): void; - getElementsAtPoint(x: number, y: number): void; - setElementPosition(elementId: string, x: number, y: number): void; - previewElementPosition(elementId: string, x: number, y: number): void; - setElementKeyframes( - elementId: string, - keyframes: Array<{ - id: string; - time: number; - properties: { x?: number; y?: number }; - }> | null, - ): void; - setElementScale(elementId: string, scale: number): void; - setElementFontSize(elementId: string, fontSize: number): void; - setElementTextContent(elementId: string, content: string): void; - setElementTextColor(elementId: string, color: string): void; - setElementTextShadow(elementId: string, enabled: boolean): void; - setElementTextFontWeight(elementId: string, weight: number): void; - setElementTextFontFamily(elementId: string, fontFamily: string): void; - setElementTextOutline(elementId: string, enabled: boolean, color?: string, width?: number): void; - setElementTextHighlight( - elementId: string, - enabled: boolean, - color?: string, - padding?: number, - radius?: number, - ): void; - setElementVolume(elementId: string, volume: number): void; - setStageZoom(scale: number, focusX: number, focusY: number): void; - getStageZoom(): { scale: number; focusX: number; focusY: number }; - setStageZoomKeyframes( - keyframes: Array<{ - id: string; - time: number; - zoom: { scale: number; focusX: number; focusY: number }; - ease?: string; - }> | null, - ): void; - getStageZoomKeyframes(): Array<{ - id: string; - time: number; - zoom: { scale: number; focusX: number; focusY: number }; - ease?: string; - }>; - addElement(data: AddElementData): boolean; - removeElement(elementId: string): boolean; - updateElementTiming(elementId: string, start?: number, end?: number): boolean; - setElementTiming( - elementId: string, - startTime: number, - duration: number, - mediaStartTime?: number, - ): void; - updateElementSrc(elementId: string, src: string): boolean; - updateElementLayer(elementId: string, zIndex: number): boolean; - updateElementBasePosition(elementId: string, x?: number, y?: number, scale?: number): boolean; - markTimelineDirty(): void; - isTimelineDirty(): boolean; - rebuildTimeline(): void; - ensureTimeline(): void; - enableRenderMode(): void; - disableRenderMode(): void; - renderSeek(time: number): void; - getElementVisibility(elementId: string): { visible: boolean; opacity?: number }; - getVisibleElements(): Array<{ id: string; tagName: string; start: number; end: number }>; - getRenderState(): { - time: number; - duration: number; - isPlaying: boolean; - renderMode: boolean; - timelineDirty: boolean; - }; -} - -export interface AddElementData { - id: string; - type: "video" | "image" | "text" | "audio" | "composition"; - name?: string; - src?: string; - content?: string; - start: number; - end: number; - zIndex?: number; - x?: number; - y?: number; - scale?: number; - fontSize?: number; - color?: string; - textShadow?: boolean; - fontWeight?: number; - textOutline?: boolean; - textOutlineColor?: string; - textOutlineWidth?: number; - textHighlight?: boolean; - textHighlightColor?: string; - textHighlightPadding?: number; - textHighlightRadius?: number; - compositionId?: string; - sourceWidth?: number; - sourceHeight?: number; - isAroll?: boolean; -} - -export interface ValidationResult { - valid: boolean; - errors: string[]; - warnings: string[]; -} - -export interface CompositionAsset { - id: string; - name: string; - type: "composition"; - src: string; - duration: number; - compositionId: string; - thumbnail?: string; -} - -export interface Keyframe { - id: string; - time: number; - properties: Partial; - ease?: string; -} - -export interface KeyframeProperties { - x: number; - y: number; - opacity: number; - scale: number; - scaleX: number; - scaleY: number; - rotation: number; - width: number; - height: number; -} - -export interface ElementKeyframes { - elementId: string; - keyframes: Keyframe[]; -} - -export interface StageZoom { - scale: number; - focusX: number; - focusY: number; -} - -export interface StageZoomKeyframe { - id: string; - time: number; - zoom: StageZoom; - ease?: string; -} - -export function getDefaultStageZoom(resolution: CanvasResolution): StageZoom { - const { width, height } = CANVAS_DIMENSIONS[resolution]; - return { - scale: 1, - focusX: width / 2, - focusY: height / 2, - }; -} + VALID_CANVAS_RESOLUTIONS, + normalizeResolutionFlag, + COMPOSITION_VARIABLE_TYPES, + TIMELINE_COLORS, + DEFAULT_DURATIONS, + getDefaultStageZoom, + isTextElement, + isMediaElement, + isCompositionElement, +} from "@hyperframes/parsers"; diff --git a/packages/parsers/src/index.ts b/packages/parsers/src/index.ts index b5d8e0c4de..a0c4ebf250 100644 --- a/packages/parsers/src/index.ts +++ b/packages/parsers/src/index.ts @@ -1,2 +1 @@ -// barrel — populated in U3 -export {}; +export * from "./types.js"; diff --git a/packages/parsers/src/types.ts b/packages/parsers/src/types.ts new file mode 100644 index 0000000000..581a4dc8ed --- /dev/null +++ b/packages/parsers/src/types.ts @@ -0,0 +1,467 @@ +// ── Composition data types ─────────────────────────────────────────────────── +// Moved from @hyperframes/core/core.types in the parsers extraction refactor. +// These are the types produced and consumed by the parser pipeline. + +export interface Asset { + id: string; + url: string; + type: string; + is_reference?: boolean; + /** Duration in seconds for video/audio assets */ + duration?: number; +} + +// ── Timeline types ────────────────────────────────────────────────────────── + +export type TimelineElementType = "video" | "image" | "text" | "audio" | "composition"; +export type MediaElementType = "video" | "image" | "audio"; + +export const CANVAS_DIMENSIONS = { + landscape: { width: 1920, height: 1080 }, + portrait: { width: 1080, height: 1920 }, + "landscape-4k": { width: 3840, height: 2160 }, + "portrait-4k": { width: 2160, height: 3840 }, + square: { width: 1080, height: 1080 }, + "square-4k": { width: 2160, height: 2160 }, +} as const; + +// Single source of truth: derive the type from the table so adding a preset +// extends the union automatically. Avoids the prior `as readonly CanvasResolution[]` +// cast on `VALID_CANVAS_RESOLUTIONS` quietly drifting if the table grew but +// the union didn't. +export type CanvasResolution = keyof typeof CANVAS_DIMENSIONS; + +// `Object.keys` ordering matches insertion order in `CANVAS_DIMENSIONS` on +// every supported JS engine; tests pin the order in `index.test.ts`. Reorder +// the table above with care. +export const VALID_CANVAS_RESOLUTIONS = Object.keys( + CANVAS_DIMENSIONS, +) as readonly CanvasResolution[]; + +const RESOLUTION_ALIASES: Record = { + "1080p": "landscape", + hd: "landscape", + "1080p-portrait": "portrait", + "portrait-1080p": "portrait", + "4k": "landscape-4k", + uhd: "landscape-4k", + "4k-portrait": "portrait-4k", + "1080p-square": "square", + "square-1080p": "square", + "4k-square": "square-4k", +}; + +/** + * Map a user-facing resolution string (canonical name or alias) to a + * `CanvasResolution`. Returns undefined for unknown values so callers + * can produce their own "invalid" UX (CLI exit, route validation, etc.). + */ +export function normalizeResolutionFlag(input: string | undefined): CanvasResolution | undefined { + if (!input) return undefined; + const lowered = input.toLowerCase(); + if ((VALID_CANVAS_RESOLUTIONS as readonly string[]).includes(lowered)) { + return lowered as CanvasResolution; + } + return RESOLUTION_ALIASES[lowered]; +} + +export interface TimelineElementBase { + id: string; + type: TimelineElementType; + name: string; + startTime: number; + duration: number; + zIndex: number; + x?: number; + y?: number; + scale?: number; + opacity?: number; +} + +export interface TimelineMediaElement extends TimelineElementBase { + type: MediaElementType; + src: string; + mediaStartTime?: number; + sourceDuration?: number; + isAroll?: boolean; + sourceWidth?: number; + sourceHeight?: number; + volume?: number; // 0-1 (0% to 100%), default 1.0 + hasAudio?: boolean; // For videos - indicates if video has audio track +} + +export interface WaveformData { + peaks: number[]; + duration: number; + sampleRate?: number; +} + +export interface TimelineTextElement extends TimelineElementBase { + type: "text"; + content: string; + color?: string; + fontSize?: number; + textShadow?: boolean; + fontFamily?: string; + fontWeight?: number; + textOutline?: boolean; + textOutlineColor?: string; + textOutlineWidth?: number; + textHighlight?: boolean; + textHighlightColor?: string; + textHighlightPadding?: number; + textHighlightRadius?: number; +} + +export interface TimelineCompositionElement extends TimelineElementBase { + type: "composition"; + src: string; + compositionId: string; + scale?: number; + sourceDuration?: number; + variableValues?: Record; + sourceWidth?: number; + sourceHeight?: number; +} + +// Composition Variable Types +export type CompositionVariableType = + | "string" + | "number" + | "color" + | "boolean" + | "enum" + | "font" + | "image"; + +/** + * Runtime list of every valid `CompositionVariableType`. Use this anywhere + * a Set/array of valid type strings is needed (lint rules, validators). + * The `satisfies` guard turns adding a new variant to the union without + * also adding it here into a compile error. + */ +export const COMPOSITION_VARIABLE_TYPES = [ + "string", + "number", + "color", + "boolean", + "enum", + "font", + "image", +] as const satisfies readonly CompositionVariableType[]; + +export interface CompositionVariableBase { + id: string; + type: CompositionVariableType; + label: string; + description?: string; +} + +export interface StringVariable extends CompositionVariableBase { + type: "string"; + default: string; + placeholder?: string; + maxLength?: number; +} + +export interface NumberVariable extends CompositionVariableBase { + type: "number"; + default: number; + min?: number; + max?: number; + step?: number; + unit?: string; +} + +export interface ColorVariable extends CompositionVariableBase { + type: "color"; + default: string; + /** Brand role identifier, e.g. "color:primary". */ + brandRole?: string; +} + +export interface BooleanVariable extends CompositionVariableBase { + type: "boolean"; + default: boolean; +} + +export interface EnumVariable extends CompositionVariableBase { + type: "enum"; + default: string; + options: { value: string; label: string }[]; +} + +/** + * Font variable — value is a `{name, source}` object (object-valued; LOCKED §7). + * `default` is the fallback font-family name string. + * `source` is the font stylesheet URL (e.g. Google Fonts CSS). + * `default_name` / `default_source` are the CSS-level fallbacks when the + * brand font is absent. + */ +export interface FontVariable extends CompositionVariableBase { + type: "font"; + /** Fallback font-family name, e.g. "Inter". */ + default: string; + /** Font stylesheet URL (e.g. Google Fonts CSS link). */ + source?: string; + /** CSS font-family name to use when source is unavailable, e.g. "sans-serif". */ + default_name?: string; + /** Fallback font stylesheet URL (empty string = system font). */ + default_source?: string; +} + +/** + * Image variable — value is a `{url, …}` object (object-valued; LOCKED §7). + * `default` is the fallback image URL string. + * `brandRole` is an optional semantic label, e.g. "logo:primary". + */ +export interface ImageVariable extends CompositionVariableBase { + type: "image"; + /** Fallback image URL. */ + default: string; + /** Brand role identifier, e.g. "logo:primary". */ + brandRole?: string; +} + +export type CompositionVariable = + | StringVariable + | NumberVariable + | ColorVariable + | BooleanVariable + | EnumVariable + | FontVariable + | ImageVariable; + +export interface CompositionSpec { + id: string; + duration: number; + variables: CompositionVariable[]; +} + +export type TimelineElement = + | TimelineMediaElement + | TimelineTextElement + | TimelineCompositionElement; + +export function isTextElement(el: TimelineElement): el is TimelineTextElement { + return el.type === "text"; +} + +export function isMediaElement(el: TimelineElement): el is TimelineMediaElement { + return el.type === "video" || el.type === "image" || el.type === "audio"; +} + +export function isCompositionElement(el: TimelineElement): el is TimelineCompositionElement { + return el.type === "composition"; +} + +export interface MediaFile { + id: string; + name: string; + type: TimelineElementType; + src: string; + file?: File; + duration?: number; + compositionId?: string; + sourceWidth?: number; // Intrinsic width for compositions + sourceHeight?: number; // Intrinsic height for compositions +} + +export const TIMELINE_COLORS: Record = { + video: "#ec4899", + image: "#3b82f6", + text: "#06b6d4", + audio: "#10b981", + composition: "#f97316", +}; + +export const DEFAULT_DURATIONS: Record = { + video: 5, + image: 5, + text: 2, + audio: 5, + composition: 5, +}; + +export interface CompositionAPI { + id: string; + duration: number; + seek(time: number): void; + getTime(): number; + getDuration(): number; +} + +// ── Player API types (used by runtime) ──────────────────────────────────── + +export interface PlayerAPI { + play(): void; + pause(): void; + seek(time: number, options?: { keepPlaying?: boolean }): void; + getTime(): number; + getDuration(): number; + isPlaying(): boolean; + getMainTimeline(): unknown; + getElementBounds(elementId: string): void; + getElementsAtPoint(x: number, y: number): void; + setElementPosition(elementId: string, x: number, y: number): void; + previewElementPosition(elementId: string, x: number, y: number): void; + setElementKeyframes( + elementId: string, + keyframes: Array<{ + id: string; + time: number; + properties: { x?: number; y?: number }; + }> | null, + ): void; + setElementScale(elementId: string, scale: number): void; + setElementFontSize(elementId: string, fontSize: number): void; + setElementTextContent(elementId: string, content: string): void; + setElementTextColor(elementId: string, color: string): void; + setElementTextShadow(elementId: string, enabled: boolean): void; + setElementTextFontWeight(elementId: string, weight: number): void; + setElementTextFontFamily(elementId: string, fontFamily: string): void; + setElementTextOutline(elementId: string, enabled: boolean, color?: string, width?: number): void; + setElementTextHighlight( + elementId: string, + enabled: boolean, + color?: string, + padding?: number, + radius?: number, + ): void; + setElementVolume(elementId: string, volume: number): void; + setStageZoom(scale: number, focusX: number, focusY: number): void; + getStageZoom(): { scale: number; focusX: number; focusY: number }; + setStageZoomKeyframes( + keyframes: Array<{ + id: string; + time: number; + zoom: { scale: number; focusX: number; focusY: number }; + ease?: string; + }> | null, + ): void; + getStageZoomKeyframes(): Array<{ + id: string; + time: number; + zoom: { scale: number; focusX: number; focusY: number }; + ease?: string; + }>; + addElement(data: AddElementData): boolean; + removeElement(elementId: string): boolean; + updateElementTiming(elementId: string, start?: number, end?: number): boolean; + setElementTiming( + elementId: string, + startTime: number, + duration: number, + mediaStartTime?: number, + ): void; + updateElementSrc(elementId: string, src: string): boolean; + updateElementLayer(elementId: string, zIndex: number): boolean; + updateElementBasePosition(elementId: string, x?: number, y?: number, scale?: number): boolean; + markTimelineDirty(): void; + isTimelineDirty(): boolean; + rebuildTimeline(): void; + ensureTimeline(): void; + enableRenderMode(): void; + disableRenderMode(): void; + renderSeek(time: number): void; + getElementVisibility(elementId: string): { visible: boolean; opacity?: number }; + getVisibleElements(): Array<{ id: string; tagName: string; start: number; end: number }>; + getRenderState(): { + time: number; + duration: number; + isPlaying: boolean; + renderMode: boolean; + timelineDirty: boolean; + }; +} + +export interface AddElementData { + id: string; + type: "video" | "image" | "text" | "audio" | "composition"; + name?: string; + src?: string; + content?: string; + start: number; + end: number; + zIndex?: number; + x?: number; + y?: number; + scale?: number; + fontSize?: number; + color?: string; + textShadow?: boolean; + fontWeight?: number; + textOutline?: boolean; + textOutlineColor?: string; + textOutlineWidth?: number; + textHighlight?: boolean; + textHighlightColor?: string; + textHighlightPadding?: number; + textHighlightRadius?: number; + compositionId?: string; + sourceWidth?: number; + sourceHeight?: number; + isAroll?: boolean; +} + +export interface ValidationResult { + valid: boolean; + errors: string[]; + warnings: string[]; +} + +export interface CompositionAsset { + id: string; + name: string; + type: "composition"; + src: string; + duration: number; + compositionId: string; + thumbnail?: string; +} + +export interface Keyframe { + id: string; + time: number; + properties: Partial; + ease?: string; +} + +export interface KeyframeProperties { + x: number; + y: number; + opacity: number; + scale: number; + scaleX: number; + scaleY: number; + rotation: number; + width: number; + height: number; +} + +export interface ElementKeyframes { + elementId: string; + keyframes: Keyframe[]; +} + +export interface StageZoom { + scale: number; + focusX: number; + focusY: number; +} + +export interface StageZoomKeyframe { + id: string; + time: number; + zoom: StageZoom; + ease?: string; +} + +export function getDefaultStageZoom(resolution: CanvasResolution): StageZoom { + const { width, height } = CANVAS_DIMENSIONS[resolution]; + return { + scale: 1, + focusX: width / 2, + focusY: height / 2, + }; +} From 4fdd4faf3d9122d56659b88aa87e1dd5fea9e8ab Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 26 Jun 2026 18:10:37 -0400 Subject: [PATCH 03/11] refactor: move parser source files to @hyperframes/parsers --- .fallowrc.jsonc | 32 +- bun.lock | 15 +- packages/core/package.json | 10 +- packages/core/src/parsers/gsapConstants.ts | 126 +- packages/core/src/parsers/gsapParser.ts | 3074 +---------------- packages/core/src/parsers/gsapParserAcorn.ts | 1233 +------ .../core/src/parsers/gsapParserExports.ts | 49 +- packages/core/src/parsers/gsapSerialize.ts | 606 +--- packages/core/src/parsers/gsapUnroll.ts | 145 +- packages/core/src/parsers/gsapWriterAcorn.ts | 2377 +------------ packages/core/src/parsers/hfIds.ts | 134 +- packages/core/src/parsers/htmlParser.ts | 871 +---- packages/core/src/parsers/springEase.ts | 90 +- packages/core/src/utils/cssSelector.ts | 16 +- packages/parsers/package.json | 17 + .../src}/__goldens__/complex.parsed.json | 0 .../src}/__goldens__/complex.serialized.js | 0 .../src}/__goldens__/fromto.parsed.json | 0 .../src}/__goldens__/fromto.serialized.js | 0 .../src}/__goldens__/minimal.parsed.json | 0 .../src}/__goldens__/minimal.serialized.js | 0 .../src}/__goldens__/moderate.parsed.json | 0 .../src}/__goldens__/moderate.serialized.js | 0 packages/parsers/src/gsapConstants.ts | 124 + .../src}/gsapInline.test.ts | 0 .../src/parsers => parsers/src}/gsapInline.ts | 0 .../src}/gsapParser.acorn.test.ts | 0 .../src}/gsapParser.golden.test.ts | 0 .../src}/gsapParser.stress.test.ts | 0 .../src}/gsapParser.test-helpers.ts | 0 .../src}/gsapParser.test.ts | 2 +- packages/parsers/src/gsapParser.ts | 3072 ++++++++++++++++ .../src}/gsapParserAcorn.computed.test.ts | 0 .../src}/gsapParserAcorn.full.test.ts | 0 packages/parsers/src/gsapParserAcorn.ts | 1231 +++++++ packages/parsers/src/gsapParserExports.ts | 47 + packages/parsers/src/gsapSerialize.ts | 604 ++++ .../src}/gsapUnroll.test.ts | 0 packages/parsers/src/gsapUnroll.ts | 143 + .../src}/gsapWriter.acorn.test.ts | 0 .../src}/gsapWriter.parity.test.ts | 0 .../src}/gsapWriter.reviewFixes.test.ts | 0 packages/parsers/src/gsapWriterAcorn.ts | 2375 +++++++++++++ .../src}/gsapWriterParity.acorn.test.ts | 0 .../src}/gsapWriterParity.corpus.test.ts | 0 .../src/parsers => parsers/src}/hfIds.test.ts | 0 packages/parsers/src/hfIds.ts | 132 + .../src}/htmlParser.roundtrip.test.ts | 2 +- .../src}/htmlParser.test.ts | 0 packages/parsers/src/htmlParser.ts | 861 +++++ packages/parsers/src/index.ts | 5 + .../src}/springEase.test.ts | 0 packages/parsers/src/springEase.ts | 88 + .../parsers => parsers/src}/stableIds.test.ts | 0 .../src/parsers => parsers/src}/test-utils.ts | 2 +- packages/parsers/src/utils/cssSelector.ts | 14 + packages/parsers/tsup.config.ts | 1 + 57 files changed, 8785 insertions(+), 8713 deletions(-) rename packages/{core/src/parsers => parsers/src}/__goldens__/complex.parsed.json (100%) rename packages/{core/src/parsers => parsers/src}/__goldens__/complex.serialized.js (100%) rename packages/{core/src/parsers => parsers/src}/__goldens__/fromto.parsed.json (100%) rename packages/{core/src/parsers => parsers/src}/__goldens__/fromto.serialized.js (100%) rename packages/{core/src/parsers => parsers/src}/__goldens__/minimal.parsed.json (100%) rename packages/{core/src/parsers => parsers/src}/__goldens__/minimal.serialized.js (100%) rename packages/{core/src/parsers => parsers/src}/__goldens__/moderate.parsed.json (100%) rename packages/{core/src/parsers => parsers/src}/__goldens__/moderate.serialized.js (100%) create mode 100644 packages/parsers/src/gsapConstants.ts rename packages/{core/src/parsers => parsers/src}/gsapInline.test.ts (100%) rename packages/{core/src/parsers => parsers/src}/gsapInline.ts (100%) rename packages/{core/src/parsers => parsers/src}/gsapParser.acorn.test.ts (100%) rename packages/{core/src/parsers => parsers/src}/gsapParser.golden.test.ts (100%) rename packages/{core/src/parsers => parsers/src}/gsapParser.stress.test.ts (100%) rename packages/{core/src/parsers => parsers/src}/gsapParser.test-helpers.ts (100%) rename packages/{core/src/parsers => parsers/src}/gsapParser.test.ts (99%) create mode 100644 packages/parsers/src/gsapParser.ts rename packages/{core/src/parsers => parsers/src}/gsapParserAcorn.computed.test.ts (100%) rename packages/{core/src/parsers => parsers/src}/gsapParserAcorn.full.test.ts (100%) create mode 100644 packages/parsers/src/gsapParserAcorn.ts create mode 100644 packages/parsers/src/gsapParserExports.ts create mode 100644 packages/parsers/src/gsapSerialize.ts rename packages/{core/src/parsers => parsers/src}/gsapUnroll.test.ts (100%) create mode 100644 packages/parsers/src/gsapUnroll.ts rename packages/{core/src/parsers => parsers/src}/gsapWriter.acorn.test.ts (100%) rename packages/{core/src/parsers => parsers/src}/gsapWriter.parity.test.ts (100%) rename packages/{core/src/parsers => parsers/src}/gsapWriter.reviewFixes.test.ts (100%) create mode 100644 packages/parsers/src/gsapWriterAcorn.ts rename packages/{core/src/parsers => parsers/src}/gsapWriterParity.acorn.test.ts (100%) rename packages/{core/src/parsers => parsers/src}/gsapWriterParity.corpus.test.ts (100%) rename packages/{core/src/parsers => parsers/src}/hfIds.test.ts (100%) create mode 100644 packages/parsers/src/hfIds.ts rename packages/{core/src/parsers => parsers/src}/htmlParser.roundtrip.test.ts (98%) rename packages/{core/src/parsers => parsers/src}/htmlParser.test.ts (100%) create mode 100644 packages/parsers/src/htmlParser.ts rename packages/{core/src/parsers => parsers/src}/springEase.test.ts (100%) create mode 100644 packages/parsers/src/springEase.ts rename packages/{core/src/parsers => parsers/src}/stableIds.test.ts (100%) rename packages/{core/src/parsers => parsers/src}/test-utils.ts (93%) create mode 100644 packages/parsers/src/utils/cssSelector.ts diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index c0de88c13f..28ac1ee485 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -142,13 +142,13 @@ "createFailedCaptureCalibrationEstimate", ], }, - // gsapParser.ts is a public-API barrel that re-exports constants, types, - // and utilities from gsapConstants, gsapSerialize, and springEase. The - // re-exports are intentional public API consumed by callers outside the + // gsapParserExports.ts is the public-API barrel that re-exports constants, + // types, and utilities from gsapConstants, gsapSerialize, and springEase. + // The re-exports are intentional public API consumed by callers outside the // changed-file set (e.g. studio, aws-lambda) and therefore appear unused // to fallow's static analysis of the PR diff. { - "file": "packages/core/src/parsers/gsapParser.ts", + "file": "packages/parsers/src/gsapParserExports.ts", "exports": [ "PROPERTY_GROUPS", "classifyPropertyGroup", @@ -165,7 +165,7 @@ // Shared test helpers consumed by gsapParser.test.ts (same file, // fallow doesn't trace intra-file test consumption). { - "file": "packages/core/src/parsers/gsapParser.test-helpers.ts", + "file": "packages/parsers/src/gsapParser.test-helpers.ts", "exports": [ "expectKeyframe", "expectKeyframesFormat", @@ -173,6 +173,12 @@ "parseSplitAndAssert", ], }, + // hfIds: EXCLUDED_TAGS is consumed by tests (htmlParser.test.ts) and + // hfIdPersist.ts but fallow's static analyzer may not trace all consumers. + { + "file": "packages/parsers/src/hfIds.ts", + "exports": ["EXCLUDED_TAGS", "mintHfId"], + }, // Shared timeline components extracted for downstream PRs in the // razor-blade stack (#1330, #1331). Consumers live on those branches. { @@ -244,6 +250,18 @@ // require intrusive middleware changes beyond this PR's scope. "minLines": 6, "ignore": [ + // gsapParser.ts: recast/babel GSAP writer — intentional duplication between + // recast and acorn parallel implementations (pre-existing, moved from core). + "packages/parsers/src/gsapParser.ts", + // Parser test files: parallel arrange/act/assert test cases — pre-existing + // duplication moved from packages/core/src/parsers/. + "packages/parsers/src/gsapParser.test.ts", + "packages/parsers/src/gsapParser.test-helpers.ts", + "packages/parsers/src/gsapWriter.parity.test.ts", + "packages/parsers/src/gsapWriterParity.corpus.test.ts", + "packages/parsers/src/gsapWriterParity.acorn.test.ts", + "packages/parsers/src/htmlParser.roundtrip.test.ts", + "packages/parsers/src/htmlParser.test.ts", // slideshowPanelHelpers.ts: setSlideNotes/addFragment/addHotspot share an // intentional parallel shape (signature + mapSlidesIn → exists-check → // map/append); the per-slide mutation differs, so a shared abstraction @@ -286,6 +304,10 @@ "ignore": [ "packages/core/src/studio-api/routes/files.ts", "packages/core/src/parsers/gsapParser.ts", + // gsapParser.ts moved to packages/parsers — same complexity rationale. + "packages/parsers/src/gsapParser.ts", + // htmlParser.ts has pre-existing complexity (moved from packages/core). + "packages/parsers/src/htmlParser.ts", // SlideshowPanel.tsx: top-level editor panel that wires several independent // sections (slides/inspector/branches/hotspot). Its cyclomatic count comes // from that fan-out; splitting it would scatter shared state without diff --git a/bun.lock b/bun.lock index b319935f11..fe57720b92 100644 --- a/bun.lock +++ b/bun.lock @@ -103,16 +103,11 @@ "name": "@hyperframes/core", "version": "0.7.11", "dependencies": { - "@babel/parser": "^7.27.0", "@chenglou/pretext": "^0.0.5", "@hyperframes/parsers": "workspace:*", - "acorn": "^8.17.0", - "acorn-walk": "^8.3.5", "bpm-detective": "^2.0.5", - "magic-string": "^0.30.21", "postcss": "^8.5.8", "postcss-selector-parser": "^7.1.2", - "recast": "^0.23.11", }, "devDependencies": { "@types/jsdom": "^28.0.0", @@ -125,7 +120,6 @@ }, "optionalDependencies": { "esbuild": "^0.25.12", - "linkedom": "^0.18.12", }, "peerDependencies": { "hono": "^4.0.0", @@ -175,7 +169,16 @@ "packages/parsers": { "name": "@hyperframes/parsers", "version": "0.7.11", + "dependencies": { + "@babel/parser": "^7.27.0", + "acorn": "^8.17.0", + "acorn-walk": "^8.3.5", + "linkedom": "^0.18.12", + "magic-string": "^0.30.21", + "recast": "^0.23.11", + }, "devDependencies": { + "@hyperframes/core": "workspace:*", "@types/node": "^25.0.10", "tsup": "^8.0.0", "tsx": "^4.21.0", diff --git a/packages/core/package.json b/packages/core/package.json index d500250e6b..80e5835a03 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -280,16 +280,11 @@ "prepublishOnly": "echo skip" }, "dependencies": { - "@babel/parser": "^7.27.0", "@chenglou/pretext": "^0.0.5", "@hyperframes/parsers": "workspace:*", - "acorn": "^8.17.0", - "acorn-walk": "^8.3.5", "bpm-detective": "^2.0.5", - "magic-string": "^0.30.21", "postcss": "^8.5.8", - "postcss-selector-parser": "^7.1.2", - "recast": "^0.23.11" + "postcss-selector-parser": "^7.1.2" }, "devDependencies": { "@types/jsdom": "^28.0.0", @@ -309,7 +304,6 @@ } }, "optionalDependencies": { - "esbuild": "^0.25.12", - "linkedom": "^0.18.12" + "esbuild": "^0.25.12" } } diff --git a/packages/core/src/parsers/gsapConstants.ts b/packages/core/src/parsers/gsapConstants.ts index 8bea681c7b..e4b99fb2df 100644 --- a/packages/core/src/parsers/gsapConstants.ts +++ b/packages/core/src/parsers/gsapConstants.ts @@ -1,124 +1,2 @@ -/** - * GSAP property and ease constants. - * - * Extracted into a standalone module so browser code can import them - * without pulling in gsapParser (which depends on recast / @babel/parser). - */ - -export const SUPPORTED_PROPS = [ - // 2D Transforms - "x", - "y", - "scale", - "scaleX", - "scaleY", - "rotation", - "skewX", - "skewY", - // 3D Transforms - "z", - "rotationX", - "rotationY", - "rotationZ", - "perspective", - "transformPerspective", - "transformOrigin", - // Visibility - "opacity", - "visibility", - "autoAlpha", - // Dimensions - "width", - "height", - // Colors - "color", - "backgroundColor", - "borderColor", - // Box model - "borderRadius", - // Typography - "fontSize", - "letterSpacing", - // Filter & Clipping - "filter", - "clipPath", - // DOM content (number counters, text roll-ups) - "innerText", -]; - -// ── Property Groups ───────────────────────────────────────────────────────── -// Each group maps to an independent GSAP tween so editing one property -// (e.g. drag → x/y) never contaminates another (e.g. scale, rotation). - -export type PropertyGroupName = "position" | "scale" | "size" | "rotation" | "visual" | "other"; - -export const PROPERTY_GROUPS: Record> = { - position: new Set(["x", "y", "xPercent", "yPercent"]), - scale: new Set(["scale", "scaleX", "scaleY"]), - size: new Set(["width", "height"]), - rotation: new Set(["rotation", "skewX", "skewY"]), - visual: new Set(["opacity", "autoAlpha"]), - other: new Set(), -}; - -const PROP_TO_GROUP = new Map(); -for (const [group, props] of Object.entries(PROPERTY_GROUPS) as [ - PropertyGroupName, - ReadonlySet, -][]) { - for (const p of props) PROP_TO_GROUP.set(p, group); -} - -export function classifyPropertyGroup(prop: string): PropertyGroupName { - return PROP_TO_GROUP.get(prop) ?? "other"; -} - -export function classifyTweenPropertyGroup( - properties: Record, -): PropertyGroupName | undefined { - const groups = new Set(); - for (const key of Object.keys(properties)) { - // transformOrigin is a modifier; `_auto` is Studio's internal endpoint marker; - // `data` is GSAP-reserved (carries the Studio hold-set tag). None is an animated - // property, so none should affect the group. - if (key === "transformOrigin" || key === "_auto" || key === "data") continue; - const g = classifyPropertyGroup(key); - groups.add(g); - } - if (groups.size === 1) return groups.values().next().value; - return undefined; -} - -export const SUPPORTED_EASES = [ - "none", - "power1.in", - "power1.out", - "power1.inOut", - "power2.in", - "power2.out", - "power2.inOut", - "power3.in", - "power3.out", - "power3.inOut", - "power4.in", - "power4.out", - "power4.inOut", - "back.in", - "back.out", - "back.inOut", - "elastic.in", - "elastic.out", - "elastic.inOut", - "bounce.in", - "bounce.out", - "bounce.inOut", - "expo.in", - "expo.out", - "expo.inOut", - "spring-gentle", - "spring-bouncy", - "spring-stiff", - "spring-wobbly", - "spring-heavy", - "steps(1)", -]; +/** @deprecated Import from @hyperframes/parsers/gsap-constants */ +export * from "@hyperframes/parsers/gsap-constants"; diff --git a/packages/core/src/parsers/gsapParser.ts b/packages/core/src/parsers/gsapParser.ts index ac40a8adf0..f4fa8bf128 100644 --- a/packages/core/src/parsers/gsapParser.ts +++ b/packages/core/src/parsers/gsapParser.ts @@ -1,3072 +1,2 @@ -/** - * Node-only GSAP AST parser. Depends on recast / @babel/parser, which compile - * to CommonJS that calls `require("fs")` — so this module must never be in the - * static import graph of isomorphic/browser code. It is reachable only via the - * `@hyperframes/core/gsap-parser` subpath (studio-api mutations + the linter). - * - * Recast-free helpers (serialization, keyframe conversion, validation, types) - * live in `./gsapSerialize` and are re-exported here so this subpath exposes the - * full surface for tests and server-side consumers. - */ -import * as recast from "recast"; -import { parse as babelParse } from "@babel/parser"; -import { - type ArcPathConfig, - type ArcPathSegment, - type GsapAnimation, - type GsapKeyframesData, - type GsapMethod, - type GsapPercentageKeyframe, - type ParsedGsap, - serializeValue as valueToCode, - safeJsKey as safeKey, - resolveConversionProps, -} from "./gsapSerialize"; - -export type { - ArcPathConfig, - ArcPathSegment, - GsapAnimation, - GsapMethod, - ParsedGsap, - GsapKeyframesData, - GsapPercentageKeyframe, - GsapKeyframeFormat, -} from "./gsapSerialize"; -export { - serializeGsapAnimations, - getAnimationsForElementId, - validateCompositionGsap, - keyframesToGsapAnimations, - gsapAnimationsToKeyframes, - SUPPORTED_PROPS, - SUPPORTED_EASES, -} from "./gsapSerialize"; -export type { PropertyGroupName } from "./gsapConstants"; -export { - PROPERTY_GROUPS, - classifyPropertyGroup, - classifyTweenPropertyGroup, -} from "./gsapConstants"; -import { classifyPropertyGroup, classifyTweenPropertyGroup } from "./gsapConstants"; -import type { PropertyGroupName } from "./gsapConstants"; -export { generateSpringEaseData, SPRING_PRESETS } from "./springEase"; -export type { SpringPreset } from "./springEase"; - -const GSAP_METHODS = new Set(["set", "to", "from", "fromTo"]); - -// ── Recast / Babel AST shape types ──────────────────────────────────────── -// -// Recast's own typings are loose (`any` everywhere). These local shapes -// capture the properties we actually access, giving us IDE navigation and -// catch-at-write-time safety without depending on @babel/types at runtime. - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -- recast AST nodes are inherently untyped -interface AstNode extends Record { - type: string; -} -// eslint-disable-next-line @typescript-eslint/no-explicit-any -- recast visitor paths are inherently untyped -interface AstPath extends Record { - node: AstNode; -} - -// ── Recast AST Helpers ────────────────────────────────────────────────────── - -type ScopeBindings = ReadonlyMap; - -function parseScript(script: string) { - return recast.parse(script, { - parser: { - parse(source: string) { - return babelParse(source, { sourceType: "script", plugins: [], tokens: true }); - }, - }, - }); -} - -function collectScopeBindings(ast: AstNode): ScopeBindings { - const bindings = new Map(); - recast.types.visit(ast, { - visitVariableDeclarator(path: AstPath) { - const name = path.node.id?.name; - const init = path.node.init; - if (name && init) { - const val = resolveNode(init, bindings); - if (val !== undefined) bindings.set(name, val); - } - this.traverse(path); - }, - }); - return bindings; -} - -function resolveNode( - node: AstNode | undefined, - scope: ReadonlyMap, -): 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; - } - return undefined; -} - -function extractLiteralValue(node: AstNode | undefined, scope: ScopeBindings): unknown { - return resolveNode(node, scope); -} - -// ── Element-target resolution ─────────────────────────────────────────────── -// -// Real compositions target tweens through element variables resolved from the -// DOM (`const kicker = root.querySelector(".kicker"); tl.to(kicker, …)`), arrays -// of them (`tl.to([a, b], …)`), `gsap.utils.toArray(".sel")`, and per-element -// loop variables (`items.forEach(el => tl.to(el, …))`) — not inline string -// selectors. To make those tweens editable we resolve each target back to the -// CSS selector(s) it addresses. Resolution is lexically scoped: the same -// variable name can mean different elements in different IIFEs. - -const QUERY_METHODS = new Set(["querySelector", "querySelectorAll"]); -const ITERATION_METHODS = new Set(["forEach", "map"]); -const SCOPE_NODE_TYPES = new Set([ - "Program", - "FunctionDeclaration", - "FunctionExpression", - "ArrowFunctionExpression", -]); - -/** - * If `node` is a DOM lookup call — `x.querySelector(".sel")`, - * `document.querySelectorAll(".sel")`, `document.getElementById("id")`, or - * `gsap.utils.toArray(".sel")` — return the CSS selector it resolves to. - * `getElementById("id")` maps to `#id`. Returns null for anything else. - */ -function selectorFromQueryCall(node: AstNode, 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; -} - -/** The nearest enclosing function/program node — the binding scope of `path`. */ -function enclosingScopeNode(path: AstPath): AstNode | null { - let p = path?.parentPath; - while (p) { - if (SCOPE_NODE_TYPES.has(p.node?.type)) return p.node; - p = p.parentPath; - } - return null; -} - -/** Scope nodes enclosing `path`, innermost first. */ -function scopeChainOf(path: AstPath): AstNode[] { - const chain: AstNode[] = []; - let p = path; - while (p) { - if (SCOPE_NODE_TYPES.has(p.node?.type)) chain.push(p.node); - p = p.parentPath; - } - return chain; -} - -/** Per-scope element bindings: scopeNode → (variable name → selector). */ -type TargetBindings = Map>; - -function addBinding( - bindings: TargetBindings, - scopeNode: AstNode, - 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); -} - -/** - * Build a lexically-scoped index of element variables → selector. Two passes: - * (1) direct DOM-lookup assignments (`const x = root.querySelector(...)`), then - * (2) iteration callback params (`coll.forEach(el => …)`), whose element type is - * the collection's selector — resolved against the pass-1 bindings. - */ -function collectTargetBindings(ast: AstNode, scope: ScopeBindings): TargetBindings { - const bindings: TargetBindings = new Map(); - - recast.types.visit(ast, { - visitVariableDeclarator(path: AstPath) { - const name = path.node.id?.name; - const selector = selectorFromQueryCall(path.node.init, scope); - const scopeNode = enclosingScopeNode(path); - if (name && selector !== null && scopeNode) addBinding(bindings, scopeNode, name, selector); - this.traverse(path); - }, - visitAssignmentExpression(path: AstPath) { - const left = path.node.left; - const selector = selectorFromQueryCall(path.node.right, scope); - const scopeNode = enclosingScopeNode(path); - if (left?.type === "Identifier" && selector !== null && scopeNode) { - addBinding(bindings, scopeNode, left.name, selector); - } - this.traverse(path); - }, - }); - - // Pass 2: forEach/map callback params take the collection's selector. - recast.types.visit(ast, { - visitCallExpression(path: AstPath) { - const node = path.node; - const callee = node.callee; - if ( - callee?.type === "MemberExpression" && - callee.property?.type === "Identifier" && - ITERATION_METHODS.has(callee.property.name) - ) { - const collectionSelector = resolveCollectionSelector(callee.object, path, 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); - } - } - this.traverse(path); - }, - }); - - return bindings; -} - -function isFunctionNode(node: AstNode): boolean { - return ( - node?.type === "ArrowFunctionExpression" || - node?.type === "FunctionExpression" || - node?.type === "FunctionDeclaration" - ); -} - -/** Resolve the selector a `.forEach`/`.map` is iterating over (variable or inline call). */ -function resolveCollectionSelector( - node: AstNode, - callPath: AstPath, - scope: ScopeBindings, - bindings: TargetBindings, -): string | null { - if (node?.type === "Identifier") return lookupBinding(node.name, callPath, bindings); - if (node?.type === "CallExpression") return selectorFromQueryCall(node, scope); - return null; -} - -/** Resolve a variable name to its selector using the lexical scope chain of `path`. */ -function lookupBinding(name: string, path: AstPath, bindings: TargetBindings): string | null { - for (const scopeNode of scopeChainOf(path)) { - const selector = bindings.get(scopeNode)?.get(name); - if (selector !== undefined) return selector; - } - return null; -} - -/** - * Resolve a tween's first argument to a CSS selector. Handles inline string - * literals, element variables (lexically scoped), arrays of elements (joined - * into a CSS group selector), inline DOM lookup / `toArray` calls, and indexed - * access (`items[i]`). Returns null when the target can't be resolved - * statically (e.g. an object-target duration anchor `tl.to({ _: 0 }, …)`, or a - * runtime-computed selector). - */ -function resolveTargetSelector( - node: AstNode, - path: AstPath, - 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 lookupBinding(node.name, path, bindings); - } - if (node.type === "CallExpression") { - return selectorFromQueryCall(node, scope); - } - if (node.type === "ArrayExpression") { - const parts = node.elements - .map((el: AstNode) => resolveTargetSelector(el, path, 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") { - // `items[i]` — the element type is the collection's selector. - return lookupBinding(node.object.name, path, bindings); - } - return null; -} - -function objectExpressionToRecord(node: AstNode, scope: ScopeBindings): Record { - const result: Record = {}; - if (node?.type !== "ObjectExpression") return result; - for (const prop of node.properties ?? []) { - if (prop.type !== "ObjectProperty" && prop.type !== "Property") 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 { - // Preserve unresolvable values as raw source text so they survive round-trips - result[key] = `__raw:${recast.print(prop.value).code}`; - } - } - return result; -} - -// ── Timeline Variable Detection ───────────────────────────────────────────── - -function isGsapTimelineCall(node: AstNode): 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; -} - -interface TimelineDetection { - timelineVar: string | null; - timelineCount: number; - defaults?: TimelineDefaults; -} - -function extractTimelineDefaults( - callNode: AstNode, - scope: ScopeBindings, -): TimelineDefaults | undefined { - const arg = callNode.arguments?.[0]; - if (!arg || arg.type !== "ObjectExpression") return undefined; - const defaultsProp = arg.properties?.find( - (p: AstNode) => isObjectProperty(p) && propKeyName(p) === "defaults", - ); - if (!defaultsProp?.value || defaultsProp.value.type !== "ObjectExpression") return undefined; - const record = objectExpressionToRecord(defaultsProp.value, scope); - const result: TimelineDefaults = {}; - if (typeof record.ease === "string") result.ease = record.ease; - if (typeof record.duration === "number") result.duration = record.duration; - return Object.keys(result).length > 0 ? result : undefined; -} - -function findTimelineVar(ast: AstNode, scope?: ScopeBindings): TimelineDetection { - let timelineVar: string | null = null; - let timelineCount = 0; - let defaults: TimelineDefaults | undefined; - const emptyScope: ScopeBindings = scope ?? new Map(); - recast.types.visit(ast, { - visitVariableDeclarator(path: AstPath) { - if (isGsapTimelineCall(path.node.init)) { - timelineCount += 1; - if (!timelineVar) { - timelineVar = path.node.id?.name ?? null; - defaults = extractTimelineDefaults(path.node.init, emptyScope); - } - } - this.traverse(path); - }, - visitAssignmentExpression(path: AstPath) { - if (isGsapTimelineCall(path.node.right)) { - timelineCount += 1; - if (!timelineVar) { - const left = path.node.left; - if (left?.type === "Identifier") timelineVar = left.name; - defaults = extractTimelineDefaults(path.node.right, emptyScope); - } - } - this.traverse(path); - }, - }); - return { timelineVar, timelineCount, defaults }; -} - -// ── Find All Tween Calls ──────────────────────────────────────────────────── - -interface TweenCallInfo { - path: AstPath; - node: AstNode; - method: GsapMethod; - selector: string; - varsArg: AstNode; - fromArg?: AstNode; - positionArg?: AstNode; - /** True for a base `gsap.set(...)` (off-timeline) rather than `tl.set(...)`. */ - global?: boolean; -} - -/** - * True when the member chain of `callNode.callee` is rooted at the timeline - * variable — `tl.to(...)` and every link of a chain `tl.to(...).to(...)`. - */ -function isTimelineRootedCall(callNode: AstNode, timelineVar: string): boolean { - let obj = callNode.callee?.object; - while (obj?.type === "CallExpression") { - obj = obj.callee?.object; - } - return obj?.type === "Identifier" && obj.name === timelineVar; -} - -function findAllTweenCalls( - ast: AstNode, - timelineVar: string, - scope: ScopeBindings, - targetBindings: TargetBindings, -): TweenCallInfo[] { - const results: TweenCallInfo[] = []; - recast.types.visit(ast, { - visitCallExpression(path: AstPath) { - const node = path.node; - const callee = node.callee; - // A base `gsap.set("#sel", props)` is an off-timeline static hold (no position, - // no keyframe marker). Treat it as an editable `set` animation so a static - // value (e.g. a 3D transform) round-trips and re-edits in place. Restricted to - // a STRING-LITERAL selector: variable-target `gsap.set(el, ...)` holds stay - // opaque surrounding source (editing them by selector would be ambiguous). - 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, timelineVar) || isGlobalSet) - ) { - const method = callee.property.name; - if (!GSAP_METHODS.has(method)) { - this.traverse(path); - return; - } - const args = node.arguments; - if (args.length < 2) { - this.traverse(path); - return; - } - const selectorValue = - resolveTargetSelector(args[0], path, scope, targetBindings) ?? "__unresolved__"; - - if (method === "fromTo") { - results.push({ - path, - node, - method: "fromTo", - selector: selectorValue, - fromArg: args[1], - varsArg: args[2], - positionArg: args[3], - }); - } else { - results.push({ - path, - node, - method: method as GsapMethod, - selector: selectorValue, - varsArg: args[1], - positionArg: args[2], - ...(isGlobalSet ? { global: true } : {}), - }); - } - } - this.traverse(path); - }, - }); - return results; -} - -/** Keys that are stored on dedicated GsapAnimation fields (not in properties/extras). */ -const BUILTIN_VAR_KEYS = new Set(["duration", "ease", "delay"]); - -/** Keys that are never preserved (callbacks / advanced patterns). */ -const DROPPED_VAR_KEYS = new Set(["onComplete", "onStart", "onUpdate", "onRepeat"]); - -/** Keys that belong in `extras` — non-editable GSAP config that must survive round-trips. */ -const EXTRAS_KEYS = new Set([ - "stagger", - "yoyo", - "repeat", - "repeatDelay", - "snap", - "overwrite", - "immediateRender", -]); - -/** - * Extract raw source text for a property in an ObjectExpression AST node. - * Returns the printed source of the value node, suitable for verbatim re-emission. - */ -function extractRawPropertySource(varsArgNode: AstNode, key: string): string | undefined { - const node = findPropertyNode(varsArgNode, key); - return node ? recast.print(node).code : undefined; -} - -/** Find the raw AST node for a named property inside an ObjectExpression. */ -function findPropertyNode(varsArgNode: AstNode, key: string): AstNode | 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; -} - -// ── Native GSAP Keyframes Parsing ────────────────────────────────────────── - -const PERCENTAGE_KEY_RE = /^(\d+(?:\.\d+)?)%$/; - -/** Extract a string-valued ease or easeEach from an AST property node. */ -function tryResolveStringProp(propValue: AstNode, scope: ScopeBindings): string | undefined { - const val = resolveNode(propValue, scope); - return typeof val === "string" ? val : undefined; -} - -/** - * Parse a `keyframes` property value from a tween vars AST node into a - * normalized `GsapKeyframesData` structure. Handles all three GSAP formats: - * percentage objects, object arrays, and simple (property-array) objects. - */ -// fallow-ignore-next-line complexity -function parseKeyframesNode( - node: AstNode | undefined, - scope: ScopeBindings, -): GsapKeyframesData | undefined { - if (!node) return undefined; - - // ── Object array format: keyframes: [ { x: 0, duration: 0.5 }, ... ] ── - if (node.type === "ArrayExpression") { - return parseObjectArrayKeyframes(node, scope); - } - - if (node.type !== "ObjectExpression") return undefined; - - // Distinguish percentage vs simple-array by inspecting property keys/values. - 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); - if (hasArrayValue) return parseSimpleArrayKeyframes(node, scope); - - return undefined; -} - -// fallow-ignore-next-line complexity -function parsePercentageKeyframes(node: AstNode, scope: ScopeBindings): 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]!); - const record = objectExpressionToRecord(prop.value, scope); - const properties: Record = {}; - 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 } : {}), - }; -} - -function computeKeyframesTotalDuration( - varsNode: AstNode, - scope: ScopeBindings, -): number | undefined { - const kfNode = (varsNode.properties ?? []).find( - (p: AstNode) => (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); - if (typeof r.duration === "number") total += r.duration; - } - return total > 0 ? total : undefined; -} - -// fallow-ignore-next-line complexity -function parseObjectArrayKeyframes(node: AstNode, scope: ScopeBindings): GsapKeyframesData { - const elements = node.elements ?? []; - const raw: Array<{ - properties: Record; - duration?: number; - ease?: string; - }> = []; - - for (const el of elements) { - if (!el || (el.type !== "ObjectExpression" && el.type !== "ObjectProperty")) { - // Skip non-object elements - if (el?.type !== "ObjectExpression") continue; - } - const record = objectExpressionToRecord(el, scope); - const properties: Record = {}; - 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 }); - } - - // Convert durations to percentage positions. If durations are present, use - // cumulative ratios; otherwise distribute evenly. - 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]!; - 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: AstNode, scope: ScopeBindings): GsapKeyframesData { - const arrayProps: Map = 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; - } - } - - // Zip arrays into percentage keyframes (evenly spaced). - 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 = {}; - for (const [key, values] of arrayProps) { - if (i < values.length) properties[key] = values[i]!; - } - keyframes.push({ percentage, properties }); - } - - return { - format: "simple-array", - keyframes, - ...(ease ? { ease } : {}), - ...(easeEach ? { easeEach } : {}), - }; -} - -// ── MotionPath Parsing ──────────────────────────────────────────────────── - -interface MotionPathParseResult { - arcPath: ArcPathConfig; - waypoints: Array<{ x: number; y: number }>; -} - -function parseMotionPathNode( - node: AstNode | undefined, - scope: ScopeBindings, -): MotionPathParseResult | undefined { - if (!node) return undefined; - - let pathNode: AstNode | undefined; - 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); - 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 }); - } - - if (coords.length < 2) return undefined; - - let waypoints: Array<{ x: number; y: number }>; - const segments: ArcPathSegment[] = []; - - if (isCubic && coords.length >= 4) { - // type: "cubic" — coords are [anchor, cp1, cp2, anchor, cp1, cp2, anchor, ...] - // Every 3rd coord starting from 0 is an anchor, the two between are control points. - waypoints = []; - waypoints.push(coords[0]!); - for (let i = 1; i + 2 < coords.length; i += 3) { - const cp1 = coords[i]!; - const cp2 = coords[i + 1]!; - const anchor = coords[i + 2]!; - waypoints.push(anchor); - segments.push({ curviness, cp1, cp2 }); - } - } else { - // Waypoint array with global curviness - waypoints = coords; - for (let i = 0; i < waypoints.length - 1; i++) { - segments.push({ curviness }); - } - } - - return { - arcPath: { enabled: true, autoRotate, segments }, - waypoints, - }; -} - -// fallow-ignore-next-line complexity -function tweenCallToAnimation( - call: TweenCallInfo, - scope: ScopeBindings, -): Omit { - const vars = objectExpressionToRecord(call.varsArg, scope); - const properties: Record = {}; - const extras: Record = {}; - 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); - if (!keyframesData && kfNode) hasUnresolvedKeyframes = true; - continue; - } - - if (key === "motionPath") { - const mpNode = findPropertyNode(call.varsArg, "motionPath"); - motionPathResult = parseMotionPathNode(mpNode, scope); - continue; - } - - if (key === "easeEach") { - // easeEach is only meaningful alongside keyframes — handled below. - continue; - } - - if (EXTRAS_KEYS.has(key)) { - // For extras, prefer the raw AST source so complex objects like - // `stagger: { each: 0.15, from: "start" }` survive verbatim. - const rawSource = extractRawPropertySource(call.varsArg, key); - 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; - } - } - - // Apply tween-level easeEach to keyframes data. - if (keyframesData && typeof vars.easeEach === "string") { - keyframesData.easeEach = vars.easeEach as string; - } - - // When motionPath is present, reconstruct x/y as keyframe waypoints. - if (motionPathResult) { - const { waypoints } = motionPathResult; - if (!keyframesData) { - // No explicit keyframes — create synthetic percentage keyframes from waypoints. - 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 { - // Merge waypoint positions into existing keyframes at matching percentages. - // If keyframe count matches waypoint count, assign positionally. - const kfs = keyframesData.keyframes; - if (kfs.length === waypoints.length) { - for (let i = 0; i < kfs.length; i++) { - kfs[i]!.properties.x = waypoints[i]!.x; - kfs[i]!.properties.y = waypoints[i]!.y; - } - } - } - // arcPath is attached below on the animation result. - } - - let fromProperties: Record | undefined; - if (call.method === "fromTo" && call.fromArg) { - fromProperties = {}; - const fromVars = objectExpressionToRecord(call.fromArg, scope); - for (const [key, val] of Object.entries(fromVars)) { - if (typeof val === "number" || typeof val === "string") { - fromProperties[key] = val; - } - } - } - - const hasPositionArg = !!call.positionArg; - const posVal = call.positionArg ? extractLiteralValue(call.positionArg, scope) : 0; - const position: number | string = - typeof posVal === "number" ? posVal : typeof posVal === "string" ? posVal : 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); - } - - const anim: Omit = { - targetSelector: call.selector, - method: call.method, - position, - properties, - fromProperties, - duration, - ease, - }; - if (!hasPositionArg) anim.implicitPosition = true; - let group = classifyTweenPropertyGroup(properties); - if (!group && keyframesData) { - const kfProps: Record = {}; - 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 (call.selector === "__unresolved__") anim.hasUnresolvedSelector = true; - return anim; -} - -// ── Timeline Position Resolution ────────────────────────────────────────── - -const GSAP_DEFAULT_DURATION = 0.5; - -// NOTE: Label-based positions (e.g. "myLabel+=0.5") are not yet resolved — -// they fall through to parseFloat which returns null for non-numeric strings. -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; -} - -function applyTimelineDefaults( - anims: Omit[], - 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; - } - } -} - -function resolveTimelinePositions(anims: Omit[]): void { - let cursor = 0; - let prevStart = 0; - for (const anim of anims) { - // A global `gsap.set(...)` is off-timeline — it's applied once at load, not - // sequenced on the master timeline. It carries no position arg, so the - // cursor-based fallback below would otherwise hand it the comp-end time - // (every prior tween's duration summed). Pin it to 0 (its load-time start) - // and don't let it advance the cursor/prevStart for following tweens. - if (anim.method === "set" && anim.global) { - anim.resolvedStart = 0; - continue; - } - const duration = anim.method === "set" ? 0 : (anim.duration ?? GSAP_DEFAULT_DURATION); - let start: number | null; - - if (anim.implicitPosition) { - start = cursor; - } else if (typeof anim.position === "number") { - start = anim.position; - } else if (typeof anim.position === "string") { - start = resolvePositionString(anim.position, cursor, prevStart); - } else { - start = cursor; - } - - if (start != null) { - anim.resolvedStart = Math.max(0, start); - prevStart = anim.resolvedStart; - cursor = Math.max(cursor, anim.resolvedStart + duration); - } - } -} - -function sortBySourcePosition(calls: TweenCallInfo[]): void { - calls.sort((a, b) => { - 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; - }); -} - -// ── Stable ID Generation ─────────────────────────────────────────────────── - -/** - * IDs are transient — recomputed on every parse, never persisted across sessions. - * They exist only in ephemeral request/response payloads, React component state, - * and the in-memory keyframe cache (rebuilt on every page load). No database, - * localStorage, or file stores animation IDs, so changing the ID format (e.g. - * adding a `-scale`/`-position` suffix) is safe. - */ -function assignStableIds(anims: Omit[]): GsapAnimation[] { - const counts = new Map(); - 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 }; - }); -} - -// ── Shared parse (AST + located tween calls) ──────────────────────────────── - -interface ParsedGsapAst { - ast: AstNode; - scope: ScopeBindings; - timelineVar: string; - detection: TimelineDetection; - /** Tween calls in document order, each paired with its stable animation id. */ - located: Array<{ id: string; call: TweenCallInfo; animation: GsapAnimation }>; -} - -/** - * Parse a script to its recast AST plus the located tween calls. The mutation - * functions reuse this so they can edit the exact call node in place (recast - * preserves all surrounding source — interleaved `gsap.set`, element variable - * declarations, the IIFE wrapper, comments and formatting). - */ -function parseGsapAst(script: string): ParsedGsapAst { - const ast = parseScript(script); - const scope = collectScopeBindings(ast); - const targetBindings = collectTargetBindings(ast, scope); - const detection = findTimelineVar(ast, scope); - const timelineVar = detection.timelineVar ?? "tl"; - const calls = findAllTweenCalls(ast, timelineVar, scope, targetBindings); - sortBySourcePosition(calls); - const rawAnims = calls.map((call) => tweenCallToAnimation(call, scope)); - applyTimelineDefaults(rawAnims, detection.defaults); - resolveTimelinePositions(rawAnims); - const animations = assignStableIds(rawAnims); - const located = animations.map((animation, i) => ({ - id: animation.id, - call: calls[i]!, - animation, - })); - return { ast, scope, timelineVar, detection, located }; -} - -// ── Public API ────────────────────────────────────────────────────────────── - -export function parseGsapScript(script: string): ParsedGsap { - try { - const { detection, timelineVar, located } = parseGsapAst(script); - const animations = located.map((l) => l.animation); - - const timelineMatch = script.match( - new RegExp( - `^[\\s\\S]*?(?:const|let|var)\\s+${timelineVar}\\s*=\\s*gsap\\.timeline\\s*\\([^)]*\\)\\s*;?`, - ), - ); - const preamble = - timelineMatch?.[0] ?? `const ${timelineVar} = gsap.timeline({ paused: true });`; - - 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.timelineVar === null) - result.unsupportedTimelinePattern = true; - return result; - } catch { - return { animations: [], timelineVar: "tl", preamble: "", postamble: "" }; - } -} - -// ── In-place AST mutation helpers ─────────────────────────────────────────── -// -// Edits operate directly on the located call's AST node and reprint via recast, -// which preserves every untouched statement. This is what lets us edit tweens -// in real compositions (variable targets, interleaved `gsap.set`, IIFE wrapper) -// without regenerating — and discarding — the surrounding code. - -/** - * Parse a value/expression snippet into a standalone AST expression node. - * Uses an assignment (`__hf__ = `) rather than wrapping in parens so an - * object literal parses as an expression without recast re-emitting the - * surrounding parentheses. - */ -function parseExpr(code: string): AstNode { - return parseScript(`__hf__ = ${code};`).program.body[0].expression.right; -} - -function propKeyName(prop: AstNode): string | undefined { - return prop?.key?.name ?? prop?.key?.value; -} - -function isObjectProperty(prop: AstNode): boolean { - return prop?.type === "ObjectProperty" || prop?.type === "Property"; -} - -/** A key the inspector treats as an editable transform/style property. */ -function isEditablePropertyKey(key: string): boolean { - return !BUILTIN_VAR_KEYS.has(key) && !DROPPED_VAR_KEYS.has(key) && !EXTRAS_KEYS.has(key); -} - -function makeObjectProperty(key: string, value: number | string): AstNode { - const obj = parseExpr(`{ ${safeKey(key)}: ${valueToCode(value)} }`); - return obj.properties[0]; -} - -/** Set (or insert) a single key on an ObjectExpression, preserving sibling keys. */ -function setVarsKey(varsArg: AstNode, key: string, value: number | string): void { - if (varsArg?.type !== "ObjectExpression") return; - const existing = varsArg.properties.find( - (p: AstNode) => isObjectProperty(p) && propKeyName(p) === key, - ); - if (existing) { - existing.value = parseExpr(valueToCode(value)); - } else { - varsArg.properties.push(makeObjectProperty(key, value)); - } -} - -/** - * Filter an ObjectExpression's properties, keeping non-editable keys - * and delegating the keep/drop decision for editable keys to `shouldKeep`. - */ -function filterEditableKeys(varsArg: AstNode, shouldKeep: (key: string) => boolean): void { - if (varsArg?.type !== "ObjectExpression") return; - varsArg.properties = varsArg.properties.filter((p: AstNode) => { - if (!isObjectProperty(p)) return true; - const key = propKeyName(p); - if (typeof key !== "string") return true; - if (!isEditablePropertyKey(key)) return true; - return shouldKeep(key); - }); -} - -/** - * Replace the editable-property keys on an ObjectExpression with `newProps`, - * leaving `duration`, `ease`, `stagger`, callbacks and other non-editable keys - * untouched. - */ -function reconcileEditableProperties( - varsArg: AstNode, - newProps: Record, -): void { - filterEditableKeys(varsArg, (key) => key in newProps); - // Upsert each new prop, preserving the order keys first appeared. - for (const [key, value] of Object.entries(newProps)) { - setVarsKey(varsArg, key, value); - } -} - -function applyEaseUpdate(varsArg: AstNode, ease: string): void { - const kfNode = findKeyframesObjectNode(varsArg); - if (kfNode) { - setVarsKey(kfNode, "easeEach", ease); - removeVarsKey(varsArg, "ease"); - } else { - setVarsKey(varsArg, "ease", ease); - } -} - -/** - * "Apply to all segments": drop every per-keyframe `ease` override so the single - * `easeEach` governs all segments uniformly (AE select-all + F9). Mirrors the - * acorn writer's resetKeyframeEases branch. - */ -function stripKeyframeEases(varsArg: AstNode): void { - const kfNode = findKeyframesObjectNode(varsArg); - const props = kfNode?.properties; - if (!Array.isArray(props)) return; - for (const entry of props) { - if (isObjectProperty(entry)) removeVarsKey(entry.value, "ease"); - } -} - -function applyUpdatesToCall( - call: TweenCallInfo, - updates: Partial & { easeEach?: string; resetKeyframeEases?: boolean }, -): void { - if (updates.properties) reconcileEditableProperties(call.varsArg, updates.properties); - if (updates.fromProperties && call.method === "fromTo" && call.fromArg) { - reconcileEditableProperties(call.fromArg, updates.fromProperties); - } - if (updates.duration !== undefined) setVarsKey(call.varsArg, "duration", updates.duration); - if (updates.easeEach !== undefined) applyEaseUpdate(call.varsArg, updates.easeEach); - else if (updates.ease !== undefined) applyEaseUpdate(call.varsArg, updates.ease); - if (updates.resetKeyframeEases) stripKeyframeEases(call.varsArg); - if (updates.position !== undefined) { - const posIdx = call.method === "fromTo" ? 3 : 2; - call.node.arguments[posIdx] = parseExpr(valueToCode(updates.position)); - } -} - -/** Walk up to the enclosing ExpressionStatement path (for prune / insertAfter). */ -function findStatementPath(path: AstPath): AstPath | null { - let p = path; - while (p) { - if (p.node?.type === "ExpressionStatement") return p; - p = p.parentPath; - } - return null; -} - -function insertAfterAnchor(parsed: ParsedGsapAst, newStatement: AstNode): void { - const lastCall = parsed.located[parsed.located.length - 1]?.call; - const anchorPath = lastCall - ? findStatementPath(lastCall.path) - : findTimelineDeclarationPath(parsed.ast, parsed.timelineVar); - if (anchorPath) { - anchorPath.insertAfter(newStatement); - } else { - parsed.ast.program.body.push(newStatement); - } -} - -/** Build the source for a single `tl.method(selector, vars, position)` call. */ -function buildTweenStatementCode(timelineVar: string, anim: Omit): string { - const selector = JSON.stringify(anim.targetSelector); - const props: Record = { ...anim.properties }; - if (anim.method !== "set" && anim.duration !== undefined) props.duration = anim.duration; - if (anim.ease) props.ease = anim.ease; - const entries = Object.entries(props).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`); - // immediateRender forces GSAP to apply the set when added to the timeline, - // not on the first seek — without it, tl.set at position 0 on a paused - // timeline is invisible until the playhead moves past 0. A base `gsap.set` - // already runs immediately, so it doesn't need (or get) the flag. - if (anim.method === "set" && !anim.global) entries.push("immediateRender: true"); - if (anim.extras) { - for (const [k, v] of Object.entries(anim.extras)) { - entries.push(`${safeKey(k)}: ${valueToCode(v as number | string)}`); - } - } - const objCode = `{ ${entries.join(", ")} }`; - const posCode = valueToCode( - typeof anim.position === "number" ? anim.position : (anim.position ?? 0), - ); - if (anim.method === "fromTo") { - const fromEntries = Object.entries(anim.fromProperties ?? {}).map( - ([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`, - ); - const fromCode = `{ ${fromEntries.join(", ")} }`; - return `${timelineVar}.fromTo(${selector}, ${fromCode}, ${objCode}, ${posCode});`; - } - // A base `gsap.set` is off the timeline: no timeline var, no position arg. - if (anim.method === "set" && anim.global) { - return `gsap.set(${selector}, ${objCode});`; - } - return `${timelineVar}.${anim.method}(${selector}, ${objCode}, ${posCode});`; -} - -export function updateAnimationInScript( - script: string, - animationId: string, - updates: Partial & { easeEach?: string; resetKeyframeEases?: boolean }, -): string { - let parsed: ParsedGsapAst; - try { - parsed = parseGsapAst(script); - } catch (e) { - console.warn("[gsap-parser] updateAnimationInScript parse failed:", e); - return script; - } - const target = parsed.located.find((l) => l.id === animationId); - if (!target) return script; - applyUpdatesToCall(target.call, updates); - return recast.print(parsed.ast).code; -} - -export function shiftPositionsInScript( - script: string, - targetSelector: string, - delta: number, -): string { - let parsed: ParsedGsapAst; - try { - parsed = parseGsapAst(script); - } catch (e) { - console.warn("[gsap-parser] shiftPositionsInScript parse failed:", e); - return script; - } - let changed = false; - for (const entry of parsed.located) { - if (entry.animation.targetSelector !== targetSelector) continue; - if (typeof entry.animation.position !== "number") continue; - const newPos = Math.max(0, Math.round((entry.animation.position + delta) * 1000) / 1000); - applyUpdatesToCall(entry.call, { position: newPos }); - changed = true; - } - return changed ? recast.print(parsed.ast).code : script; -} - -export function scalePositionsInScript( - script: string, - targetSelector: string, - oldStart: number, - oldDuration: number, - newStart: number, - newDuration: number, -): string { - if (oldDuration <= 0 || newDuration <= 0) return script; - const ratio = newDuration / oldDuration; - let parsed: ParsedGsapAst; - try { - parsed = parseGsapAst(script); - } catch (e) { - console.warn("[gsap-parser] scalePositionsInScript parse failed:", e); - return script; - } - let changed = false; - for (const entry of parsed.located) { - if (entry.animation.targetSelector !== targetSelector) continue; - if (typeof entry.animation.position !== "number") continue; - const newPos = Math.max( - 0, - Math.round((newStart + (entry.animation.position - oldStart) * ratio) * 1000) / 1000, - ); - const updates: Partial = { position: newPos }; - if (typeof entry.animation.duration === "number" && entry.animation.duration > 0) { - updates.duration = Math.max( - 0.001, - Math.round(entry.animation.duration * ratio * 1000) / 1000, - ); - } - applyUpdatesToCall(entry.call, updates); - changed = true; - } - return changed ? recast.print(parsed.ast).code : script; -} - -function updateAnimationSelector(script: string, animationId: string, newSelector: string): string { - let parsed: ParsedGsapAst; - try { - parsed = parseGsapAst(script); - } catch { - return script; - } - const target = parsed.located.find((l) => l.id === animationId); - if (!target) return script; - const selectorArg = target.call.path.node.arguments?.[0]; - if (selectorArg?.type === "StringLiteral") { - selectorArg.value = newSelector; - } else if (selectorArg?.type === "Identifier") { - target.call.path.node.arguments[0] = { type: "StringLiteral", value: newSelector }; - } - return recast.print(parsed.ast).code; -} - -export function addAnimationToScript( - script: string, - animation: Omit, -): { script: string; id: string } { - let parsed: ParsedGsapAst; - try { - parsed = parseGsapAst(script); - } catch (e) { - console.warn("[gsap-parser] addAnimationToScript parse failed:", e); - return { script, id: "" }; - } - // Nothing to anchor against and no timeline to target — treat as parse failure. - if (parsed.located.length === 0 && parsed.detection.timelineVar === null) { - return { script, id: "" }; - } - - const id = `anim-${Date.now()}`; - const statementCode = buildTweenStatementCode(parsed.timelineVar, animation); - const newStatement = parseScript(statementCode).program.body[0]; - insertAfterAnchor(parsed, newStatement); - return { script: recast.print(parsed.ast).code, id }; -} - -export function addAnimationWithKeyframesToScript( - script: string, - targetSelector: string, - position: number, - duration: number, - keyframes: Array<{ - percentage: number; - properties: Record; - ease?: string; - auto?: boolean; - }>, - ease?: string, - easeEach?: string, -): { script: string; id: string } { - let parsed: ParsedGsapAst; - try { - parsed = parseGsapAst(script); - } catch (e) { - console.warn("[gsap-parser] addAnimationWithKeyframesToScript parse failed:", e); - return { script, id: "" }; - } - if (parsed.located.length === 0 && parsed.detection.timelineVar === null) { - return { script, id: "" }; - } - - const selector = JSON.stringify(targetSelector); - const kfCode = buildKeyframeObjectCode(keyframes, easeEach ? { easeEach } : undefined); - const varEntries = [`keyframes: ${kfCode}`, `duration: ${valueToCode(duration)}`]; - if (ease) varEntries.push(`ease: ${JSON.stringify(ease)}`); - const posCode = valueToCode(position); - const stmtCode = `${parsed.timelineVar}.to(${selector}, { ${varEntries.join(", ")} }, ${posCode});`; - - const newStatement = parseScript(stmtCode).program.body[0]; - insertAfterAnchor(parsed, newStatement); - - const result = recast.print(parsed.ast).code; - const reParsed = parseGsapAst(result); - const newId = reParsed.located[reParsed.located.length - 1]?.id ?? ""; - return { script: result, id: newId }; -} - -/** Find the statement path of `const = gsap.timeline(...)`. */ -function findTimelineDeclarationPath(ast: AstNode, timelineVar: string): AstPath | null { - let found: AstPath | null = null; - recast.types.visit(ast, { - visitVariableDeclaration(path: AstPath) { - if (found) return false; - for (const decl of path.node.declarations ?? []) { - if (decl.id?.name === timelineVar && isGsapTimelineCall(decl.init)) { - found = path; - return false; - } - } - this.traverse(path); - }, - }); - return found; -} - -/** Find the call that chains off `targetNode` (i.e. whose callee object IS it). */ -function findChainParentCall(stmtNode: AstNode, targetNode: AstNode): AstNode | null { - let found: AstNode | null = null; - recast.types.visit(stmtNode, { - visitCallExpression(p: AstPath) { - if (found) return false; - if (p.node.callee?.type === "MemberExpression" && p.node.callee.object === targetNode) { - found = p.node; - return false; - } - this.traverse(p); - }, - }); - return found; -} - -export function removeAnimationFromScript(script: string, animationId: string): string { - let parsed: ParsedGsapAst; - try { - parsed = parseGsapAst(script); - } catch (e) { - console.warn("[gsap-parser] removeAnimationFromScript parse failed:", e); - return script; - } - let target = parsed.located.find((l) => l.id === animationId); - if (!target) { - const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-"); - target = parsed.located.find((l) => l.id === convertedId); - } - if (!target) return script; - const node = target.call.node; - const stmtPath = findStatementPath(target.call.path); - if (!stmtPath) return script; - - const parentCall = findChainParentCall(stmtPath.node, node); - if (parentCall) { - // Inner link of a chain — splice it out by re-pointing the next link. - parentCall.callee.object = node.callee.object; - } else if (node.callee?.object?.type === "CallExpression") { - // Outermost link of a chain with earlier links — drop just this link. - stmtPath.node.expression = node.callee.object; - } else { - // Standalone tween — remove the whole statement. - stmtPath.prune(); - } - return recast.print(parsed.ast).code; -} - -function insertInheritedStateSet( - script: string, - selector: string, - position: number, - properties: Record, -): string { - let parsed: ParsedGsapAst; - try { - parsed = parseGsapAst(script); - } catch { - return script; - } - const tlVar = parsed.timelineVar; - const props = Object.entries(properties) - .map(([k, v]) => `${k}: ${typeof v === "string" ? JSON.stringify(v) : v}`) - .join(", "); - const code = `${tlVar}.set(${JSON.stringify(selector)}, { ${props} }, ${position});`; - const newStatement = parseScript(code).program.body[0]; - const anchor = findTimelineDeclarationPath(parsed.ast, tlVar); - if (anchor) { - anchor.insertAfter(newStatement); - } else if (parsed.located.length > 0) { - const firstTween = parsed.located[0]!.call; - const stmtPath = findStatementPath(firstTween.path); - if (stmtPath) stmtPath.insertBefore(newStatement); - else parsed.ast.program.body.unshift(newStatement); - } else { - parsed.ast.program.body.push(newStatement); - } - return recast.print(parsed.ast).code; -} - -/** Marker on Studio-emitted pre-keyframe hold `set`s. `data` is a GSAP-reserved - * config key (attached to the tween, never applied to the target), so it carries - * the tag without triggering GSAP's "Invalid property" warning. */ -const STUDIO_HOLD_MARKER = "hf-hold"; - -/** True for a `tl.set(...)` this module emitted to hold a keyframe before its tween. - * The Studio filters these out so they never appear as user keyframes/diamonds. */ -export function isStudioHoldSet(anim: GsapAnimation): boolean { - return anim.method === "set" && anim.properties?.data === STUDIO_HOLD_MARKER; -} - -/** - * Keep a `tl.set(selector, {x,y}, 0)` "hold" in front of every position-keyframed - * tween that starts after t=0, so the element holds its first keyframe's position - * BEFORE the tween plays instead of snapping to its CSS base (the universal NLE - * "hold before first keyframe" behavior). The set is tagged with `data: "hf-hold"` - * so this pass owns it: every call wipes the prior holds and recomputes from the - * current keyframes, keeping them in sync as keyframes are added/moved/deleted. - * - * Idempotent. Only position props (x/y/xPercent/yPercent) are held — opacity/scale - * keep their authored pre-tween behavior. A tween already starting at 0 needs no - * hold (no gap before it). - */ -export function syncPositionHoldsBeforeKeyframes(script: string): string { - let parsed: ParsedGsap; - try { - parsed = parseGsapScript(script); - } catch { - return script; - } - // 1. Drop every hold this pass previously emitted, so we recompute fresh. - let result = script; - const staleHoldIds = parsed.animations.filter(isStudioHoldSet).map((a) => a.id); - for (const id of staleHoldIds) result = removeAnimationFromScript(result, id); - - // 2. Re-add a hold for each position-keyframed tween that starts after t=0. - let reparsed: ParsedGsap; - try { - reparsed = parseGsapScript(result); - } catch { - return result; - } - for (const anim of reparsed.animations) { - if (!anim.keyframes) continue; - const start = anim.resolvedStart ?? (typeof anim.position === "number" ? anim.position : 0); - if (!(start > 0.001)) continue; - const firstKf = [...anim.keyframes.keyframes].sort((a, b) => a.percentage - b.percentage)[0]; - if (!firstKf) continue; - const posProps: Record = {}; - for (const [k, v] of Object.entries(firstKf.properties)) { - if (classifyPropertyGroup(k) === "position" && typeof v === "number") posProps[k] = v; - } - if (Object.keys(posProps).length === 0) continue; - result = insertInheritedStateSet(result, anim.targetSelector, 0, { - ...posProps, - data: STUDIO_HOLD_MARKER, - }); - } - return result; -} - -// ── Split Animation Functions ───────────────────────────────────────────── - -export interface SplitAnimationsOptions { - originalId: string; - newId: string; - splitTime: number; - elementStart: number; - elementDuration: number; -} - -export interface SplitAnimationsResult { - script: string; - /** Non-ID-selector animations that the engine cannot safely retarget. */ - skippedSelectors: string[]; -} - -// fallow-ignore-next-line complexity -export function splitAnimationsInScript( - script: string, - opts: SplitAnimationsOptions, -): SplitAnimationsResult { - const parsed = parseGsapScript(script); - const originalSelector = `#${opts.originalId}`; - const newSelector = `#${opts.newId}`; - - const skippedSelectors: string[] = []; - for (const a of parsed.animations) { - if (a.targetSelector !== originalSelector && a.targetSelector.includes(opts.originalId)) { - skippedSelectors.push(a.targetSelector); - } - } - - const matching = parsed.animations.filter((a) => a.targetSelector === originalSelector); - if (matching.length === 0) return { script, skippedSelectors }; - - let result = script; - const newElementStart = opts.splitTime; - const inheritedProps: Record = {}; - - // Reverse iteration: updateAnimationSelector mutates selectors in the source - // string, which can shift count-based ID suffixes (e.g. "#hero-1" → "#hero-2") - // for later animations. Processing last-to-first prevents stale ID collisions. - for (let i = matching.length - 1; i >= 0; i--) { - const anim = matching[i]!; - const pos = typeof anim.position === "number" ? anim.position : 0; - const dur = anim.duration ?? 0; - const animEnd = pos + dur; - - if (anim.keyframes) { - if (pos >= opts.splitTime) { - result = updateAnimationSelector(result, anim.id, newSelector); - } else if (animEnd > opts.splitTime) { - // Spanning keyframes can't be correctly split without renormalizing - // percentages and durations — leave on original, warn the caller. - skippedSelectors.push(`${originalSelector} (keyframes spanning split)`); - const kfs = anim.keyframes.keyframes; - for (const kf of kfs) { - const kfTime = pos + (kf.percentage / 100) * dur; - if (kfTime <= opts.splitTime) { - for (const [k, v] of Object.entries(kf.properties)) { - inheritedProps[k] = v; - } - } - } - } else { - // Entirely before split — extract final keyframe properties - const kfs = anim.keyframes.keyframes; - if (kfs.length > 0) { - for (const [k, v] of Object.entries(kfs[kfs.length - 1]!.properties)) { - inheritedProps[k] = v; - } - } - } - continue; - } - - // `<=` (not `<`) is deliberate: a tween whose end coincides exactly with - // the split boundary has fully played by splitTime, so it belongs to the - // first half and contributes its resting state to the clone. The spanning - // branch below handles only strictly-mid-flight tweens (pos < split < end). - if (animEnd <= opts.splitTime) { - // Only a completed .from() reverts the element to its natural state, so - // its recorded properties are the HIDDEN start (e.g. opacity:0), not the - // resting state — clearing them keeps the clone at its natural value - // instead of pinning it to the from-values (which made it invisible). - // .fromTo() and .to() both END at their to-values (no revert), so they - // fall through to `else` and inherit `anim.properties` (the to-values) — - // .fromTo() must NOT join the .from() clear-branch or the clone would - // drop the very state the fromTo just established. - if (anim.method === "from") { - for (const k of Object.keys(anim.properties)) delete inheritedProps[k]; - } else { - for (const [k, v] of Object.entries(anim.properties)) { - inheritedProps[k] = v; - } - } - continue; - } - - if (pos >= opts.splitTime) { - result = updateAnimationSelector(result, anim.id, newSelector); - continue; - } - - // Spans the split — use linear interpolation to compute mid-values, - // then .fromTo() on the clone so both halves play the correct range. - // For .fromTo() tweens we have explicit from-values; for .to() tweens - // we use accumulated state from prior animations, defaulting to 0 for - // unknown numeric properties (the standard GSAP transform initial state). - const progress = dur > 0 ? (opts.splitTime - pos) / dur : 0; - const fromSource = anim.fromProperties ?? inheritedProps; - const midProps: Record = {}; - for (const [k, v] of Object.entries(anim.properties)) { - if (typeof v !== "number") { - midProps[k] = v; - continue; - } - const fromVal = typeof fromSource[k] === "number" ? (fromSource[k] as number) : 0; - midProps[k] = fromVal + (v - fromVal) * progress; - } - - const firstHalfDuration = opts.splitTime - pos; - result = updateAnimationInScript(result, anim.id, { - duration: firstHalfDuration, - properties: midProps, - }); - - const secondHalfDuration = animEnd - opts.splitTime; - const addResult = addAnimationToScript(result, { - targetSelector: newSelector, - method: "fromTo", - position: newElementStart, - duration: secondHalfDuration, - properties: { ...anim.properties }, - fromProperties: { ...midProps }, - ease: anim.ease, - extras: anim.extras, - }); - result = addResult.script; - - for (const [k, v] of Object.entries(midProps)) { - inheritedProps[k] = v; - } - } - - if (Object.keys(inheritedProps).length > 0) { - result = insertInheritedStateSet(result, newSelector, newElementStart, inheritedProps); - } - - return { script: result, skippedSelectors }; -} - -// ── Keyframe Mutation Functions ──────────────────────────────────────────── - -function sortedKeyframes( - kfs: Array<{ percentage: number; properties: Record; ease?: string }>, -) { - return kfs.slice().sort((a, b) => a.percentage - b.percentage); -} - -function keyframePropsToCode(kf: { properties: Record }): string[] { - return Object.entries(kf.properties).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`); -} - -function buildKeyframeObjectCode( - keyframes: Array<{ - percentage: number; - properties: Record; - ease?: string; - auto?: boolean; - }>, - options?: { easeEach?: string }, -): string { - const entries = keyframes.map((kf) => { - const props = keyframePropsToCode(kf); - if (kf.ease) props.push(`ease: ${JSON.stringify(kf.ease)}`); - if (kf.auto) props.push(`_auto: 1`); - return `${JSON.stringify(`${kf.percentage}%`)}: { ${props.join(", ")} }`; - }); - if (options?.easeEach) entries.push(`easeEach: ${JSON.stringify(options.easeEach)}`); - return `{ ${entries.join(", ")} }`; -} - -/** Remove a named property from an ObjectExpression's properties array. */ -function removeVarsKey(varsArg: AstNode, key: string): void { - if (varsArg?.type !== "ObjectExpression") return; - varsArg.properties = varsArg.properties.filter( - (p: AstNode) => !(isObjectProperty(p) && propKeyName(p) === key), - ); -} - -/** Extract the numeric percentage from a key like "50%". Returns NaN for non-percentage keys. */ -function percentageFromKey(key: string): number { - const m = PERCENTAGE_KEY_RE.exec(key); - return m ? Number.parseFloat(m[1]!) : Number.NaN; -} - -const PCT_TOLERANCE = 2; - -function findKeyframePropByPct( - kfNode: AstNode, - percentage: number, -): { idx: number; prop: AstNode } | null { - const props = kfNode.properties; - for (let i = 0; i < props.length; i++) { - if (!isObjectProperty(props[i])) continue; - const key = propKeyName(props[i]); - if (typeof key !== "string") continue; - const parsed = percentageFromKey(key); - if (Number.isNaN(parsed)) continue; - if (Math.abs(parsed - percentage) <= PCT_TOLERANCE) return { idx: i, prop: props[i] }; - } - return null; -} - -/** Build a keyframe value AST node from properties and optional ease. */ -function buildKeyframeValueNode( - properties: Record, - ease?: string, -): AstNode { - const entries = Object.entries(properties).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`); - if (ease) entries.push(`ease: ${JSON.stringify(ease)}`); - return parseExpr(`{ ${entries.join(", ")} }`); -} - -/** Parse + locate a target animation, returning null on failure. */ -function locateAnimation( - script: string, - animationId: string, -): { parsed: ParsedGsapAst; target: ParsedGsapAst["located"][number] } | null { - let parsed: ParsedGsapAst; - try { - parsed = parseGsapAst(script); - } catch { - return null; - } - const target = parsed.located.find((l) => l.id === animationId); - return target ? { parsed, target } : null; -} - -// Animation ids encode the tween's timeline position in ms -// (`#puck-a-to-1200-position`). A gesture/convert can re-emit a tween at a -// different position, changing its id — so a client that cached the old id (its -// selectedGsapAnimations hasn't refreshed) edits a now-nonexistent id and the op -// no-ops. Parse `{selector}-{method}-{posMs}-{group}` so we can fall back to the -// same selector+method+group tween nearest the requested position. -const ANIM_ID_RE = /^(.*)-(fromTo|from|to|set)-(\d+)-([a-z]+)$/; - -function locateAnimationWithFallback( - script: string, - animationId: string, -): ReturnType { - const loc = locateAnimation(script, animationId); - if (loc) return loc; - const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-"); - if (convertedId !== animationId) { - const converted = locateAnimation(script, convertedId); - if (converted) return converted; - } - // Position-drift fallback: match by stable identity (selector+method+group), - // disambiguating by the position closest to the one the caller asked for. - const want = ANIM_ID_RE.exec(animationId); - if (!want) return null; - const [, sel, method, wantPosStr, group] = want; - const wantPos = Number(wantPosStr); - let parsed: ParsedGsapAst; - try { - parsed = parseGsapAst(script); - } catch { - return null; - } - let best: ParsedGsapAst["located"][number] | null = null; - let bestDist = Number.POSITIVE_INFINITY; - for (const l of parsed.located) { - const m = ANIM_ID_RE.exec(l.id); - if (!m || m[1] !== sel || m[2] !== method || m[4] !== group) continue; - const dist = Math.abs(Number(m[3]) - wantPos); - if (dist < bestDist) { - best = l; - bestDist = dist; - } - } - return best ? { parsed, target: best } : null; -} - -/** Find the keyframes ObjectExpression node on a tween's varsArg, or null. */ -function findKeyframesObjectNode(varsArg: AstNode): AstNode | null { - const node = findPropertyNode(varsArg, "keyframes"); - return node?.type === "ObjectExpression" ? node : null; -} - -/** - * Convert array-form keyframes (`keyframes: [{x,y}, …]`) to even-percentage object - * form (`{ "0%": {…}, "33.3%": {…}, … }`) IN PLACE, returning the new object node - * (or null if not array-form). GSAP distributes an array evenly, so this is - * runtime-identical — but it gives the percentage-keyed write ops something to - * target. Needed before INSERTING a keyframe at an arbitrary percentage, which an - * even array can't host. - */ -function convertArrayKeyframesToObjectNode(varsArg: AstNode): AstNode | null { - if (varsArg?.type !== "ObjectExpression") return null; - const prop = (varsArg.properties ?? []).find( - (p: AstNode) => isObjectProperty(p) && propKeyName(p) === "keyframes", - ); - if (!prop || prop.value?.type !== "ArrayExpression") return null; - const els: AstNode[] = (prop.value.elements ?? []).filter( - (e: AstNode | null): e is AstNode => !!e && e.type === "ObjectExpression", - ); - const n = els.length; - if (n === 0) return null; - const entries = els.map((el: AstNode, i: number) => { - const pct = n > 1 ? Math.round((i / (n - 1)) * 1000) / 10 : 0; - return `${JSON.stringify(`${pct}%`)}: ${recast.print(el).code}`; - }); - prop.value = parseExpr(`{ ${entries.join(", ")} }`); - return prop.value; -} - -/** Filter percentage-keyed properties from a keyframes ObjectExpression. */ -function filterPercentageProps(kfNode: AstNode): AstNode[] { - return kfNode.properties.filter((p: AstNode) => { - if (!isObjectProperty(p)) return false; - const key = propKeyName(p); - return typeof key === "string" && PERCENTAGE_KEY_RE.test(key); - }); -} - -/** - * Collapse a keyframes node to flat tween: apply `record` entries as vars keys, - * then remove `keyframes` and `easeEach` from varsArg. Skips the `ease` key - * from the record (per-keyframe ease, not a tween ease). - */ -function collapseKeyframesToFlat(varsArg: AstNode, record: Record): void { - for (const [k, v] of Object.entries(record)) { - if (k === "ease") continue; - if (typeof v === "number" || typeof v === "string") setVarsKey(varsArg, k, v); - } - removeVarsKey(varsArg, "keyframes"); - removeVarsKey(varsArg, "easeEach"); -} - -/** - * Locate an animation's keyframes ObjectExpression and build the percentage key. - * Shared preamble for addKeyframeToScript, removeKeyframeFromScript, and - * updateKeyframeInScript. - */ -function locateKeyframeCtx(script: string, animationId: string, percentage: number) { - const loc = locateAnimationWithFallback(script, animationId); - if (!loc) return null; - const kfNode = findKeyframesObjectNode(loc.target.call.varsArg); - if (!kfNode) return null; - return { loc, kfNode, pctKey: `${percentage}%` }; -} - -/** - * Insert a keyframe at the given percentage in an existing percentage-keyframes - * object. If the percentage already exists, its value is replaced. - */ -export function addKeyframeToScript( - script: string, - animationId: string, - percentage: number, - properties: Record, - ease?: string, - backfillDefaults?: Record, -): string { - let loc = locateAnimationWithFallback(script, animationId); - if (!loc) return script; - let kfNode = findKeyframesObjectNode(loc.target.call.varsArg); - - // Array-form keyframes can't host an arbitrary new percentage — normalize to - // object form in place first. (convertToKeyframesInScript below only converts - // FLAT tweens; it early-returns when keyframes already exist.) - if (!kfNode) kfNode = convertArrayKeyframesToObjectNode(loc.target.call.varsArg); - - if (!kfNode) { - script = convertToKeyframesInScript(script, animationId); - loc = locateAnimationWithFallback(script, animationId); - if (!loc) return script; - kfNode = findKeyframesObjectNode(loc.target.call.varsArg); - if (!kfNode) return script; - } - const pctKey = `${percentage}%`; - - const newValueNode = buildKeyframeValueNode(properties, ease); - - // Merge into existing keyframe at this percentage, or insert new - const existing = findKeyframePropByPct(kfNode, percentage); - if (existing) { - if (existing.prop.value?.type === "ObjectExpression") { - const existingRecord = objectExpressionToRecord(existing.prop.value, loc.parsed.scope); - const merged = { ...existingRecord }; - for (const [k, v] of Object.entries(properties)) merged[k] = v; - existing.prop.value = buildKeyframeValueNode( - merged as Record, - ease ?? (typeof existingRecord.ease === "string" ? existingRecord.ease : undefined), - ); - } else { - existing.prop.value = newValueNode; - } - } else { - // Build the new property node with a quoted percentage key - const newProp = parseExpr(`{ ${JSON.stringify(pctKey)}: {} }`).properties[0]; - newProp.value = newValueNode; - - // Insert in sorted order by percentage - let insertIdx = kfNode.properties.length; - for (let i = 0; i < kfNode.properties.length; i++) { - const key = isObjectProperty(kfNode.properties[i]) - ? propKeyName(kfNode.properties[i]) - : undefined; - if (typeof key === "string" && percentageFromKey(key) > percentage) { - insertIdx = i; - break; - } - } - kfNode.properties.splice(insertIdx, 0, newProp); - } - - // Auto-update adjacent endpoints: only update an `_auto` 0% or 100% - // keyframe when the new keyframe is directly next to it (no other keyframe - // between them). This prevents a keyframe at 74% from clobbering 100% when - // 75% already exists, and a keyframe at 30% from clobbering 0% when 25% - // already exists. - if (percentage > 0 && percentage < 100) { - const pctProps = filterPercentageProps(kfNode); - const allPcts = pctProps - .map((p: AstNode) => percentageFromKey(propKeyName(p) ?? "")) - .filter((n: number) => !Number.isNaN(n) && n !== percentage) - .sort((a: number, b: number) => a - b); - const leftNeighbor = allPcts.filter((p: number) => p < percentage).pop(); - const rightNeighbor = allPcts.find((p: number) => p > percentage); - for (const endPct of [0, 100]) { - const isNeighbor = endPct === 0 ? leftNeighbor === 0 : rightNeighbor === 100; - if (!isNeighbor) continue; - const endProp = pctProps.find( - (p: AstNode) => percentageFromKey(propKeyName(p) ?? "") === endPct, - ); - if (!endProp?.value || endProp.value.type !== "ObjectExpression") continue; - const hasAuto = endProp.value.properties.some( - (p: AstNode) => isObjectProperty(p) && propKeyName(p) === "_auto", - ); - if (!hasAuto) continue; - const updatedProps = { ...properties, _auto: 1 as number | string }; - endProp.value = buildKeyframeValueNode(updatedProps, undefined); - } - } - - // Backfill: when the new keyframe introduces properties absent from other - // keyframes, add default values so GSAP can interpolate them. - if (backfillDefaults) { - const newPropKeys = Object.keys(properties); - const pctProps = filterPercentageProps(kfNode); - for (const prop of pctProps) { - const key = propKeyName(prop); - if (key === pctKey) continue; - const valObj = prop.value; - if (!valObj || valObj.type !== "ObjectExpression") continue; - const existingKeys = new Set( - valObj.properties - .filter((p: AstNode) => isObjectProperty(p)) - .map((p: AstNode) => propKeyName(p)), - ); - for (const pk of newPropKeys) { - if (existingKeys.has(pk)) continue; - const defaultVal = backfillDefaults[pk]; - if (defaultVal == null) continue; - const fillProp = parseExpr(`{ ${safeKey(pk)}: ${valueToCode(defaultVal)} }`).properties[0]; - valObj.properties.push(fillProp); - } - } - } - - return recast.print(loc.parsed.ast).code; -} - -/** - * Remove a keyframe at the given percentage. If fewer than 2 keyframes remain - * after removal, collapse the keyframes object to a flat tween using the - * remaining keyframe's properties. - */ -export function removeKeyframeFromScript( - script: string, - animationId: string, - percentage: number, -): string { - // Array-form keyframes (`keyframes: [{x,y}, …]`) have no explicit percentages — - // GSAP distributes them evenly. The object-form path below can't see them - // (findKeyframesObjectNode only matches ObjectExpression), so removing from an - // array-form tween silently no-op'd. Resolve the element by its implicit - // percentage and splice it; collapse to a flat tween when fewer than two remain. - const arrLoc = locateAnimationWithFallback(script, animationId); - // findPropertyNode here returns the property's VALUE node directly. - const arrVal = arrLoc && findPropertyNode(arrLoc.target.call.varsArg, "keyframes"); - if (arrLoc && arrVal?.type === "ArrayExpression") { - const elements: AstNode[] = (arrVal.elements ?? []).filter( - (e: AstNode | null): e is AstNode => !!e && e.type === "ObjectExpression", - ); - const n = elements.length; - if (n === 0) return script; - let matchIdx = -1; - let bestDist = Number.POSITIVE_INFINITY; - for (let i = 0; i < n; i++) { - const pct = n > 1 ? (i / (n - 1)) * 100 : 0; - const dist = Math.abs(pct - percentage); - if (dist <= PCT_TOLERANCE && dist < bestDist) { - matchIdx = i; - bestDist = dist; - } - } - if (matchIdx === -1) return script; - const remaining = elements.filter((_, i) => i !== matchIdx); - if (remaining.length < 2) { - const sole = remaining[0]; - const record = sole ? objectExpressionToRecord(sole, arrLoc.parsed.scope) : {}; - collapseKeyframesToFlat(arrLoc.target.call.varsArg, record); - } else { - const realIdx = arrVal.elements.indexOf(elements[matchIdx]); - arrVal.elements.splice(realIdx, 1); - } - return recast.print(arrLoc.parsed.ast).code; - } - - const ctx = locateKeyframeCtx(script, animationId, percentage); - if (!ctx) return script; - const { loc, kfNode } = ctx; - - const match = findKeyframePropByPct(kfNode, percentage); - if (!match) return script; - const removeIdx = match.idx; - - kfNode.properties.splice(removeIdx, 1); - - const remainingKfs = filterPercentageProps(kfNode); - if (remainingKfs.length < 2) { - const record = - remainingKfs.length === 1 - ? objectExpressionToRecord(remainingKfs[0]!.value, loc.parsed.scope) - : {}; - collapseKeyframesToFlat(loc.target.call.varsArg, record); - } - - return recast.print(loc.parsed.ast).code; -} - -/** - * Replace the properties (and optionally ease) at an existing keyframe percentage. - */ -export function updateKeyframeInScript( - script: string, - animationId: string, - percentage: number, - properties: Record, - ease?: string, -): string { - // Array-form keyframes (`keyframes: [{x,y}, …]`) have no explicit percentages — - // GSAP distributes them evenly. The percentage-keyed object path below can't - // match them (findKeyframesObjectNode only matches ObjectExpression), so dragging - // a motion-path node on an array-authored tween silently no-op'd. Resolve the - // element by its implicit percentage and replace it in place. Mirrors the array - // branch in removeKeyframeFromScript. - const arrLoc = locateAnimationWithFallback(script, animationId); - const arrVal = arrLoc && findPropertyNode(arrLoc.target.call.varsArg, "keyframes"); - if (arrLoc && arrVal?.type === "ArrayExpression") { - const elements: AstNode[] = (arrVal.elements ?? []).filter( - (e: AstNode | null): e is AstNode => !!e && e.type === "ObjectExpression", - ); - const n = elements.length; - if (n === 0) return script; - let matchIdx = -1; - let bestDist = Number.POSITIVE_INFINITY; - for (let i = 0; i < n; i++) { - const pct = n > 1 ? (i / (n - 1)) * 100 : 0; - const dist = Math.abs(pct - percentage); - if (dist <= PCT_TOLERANCE && dist < bestDist) { - matchIdx = i; - bestDist = dist; - } - } - if (matchIdx === -1) return script; - const realIdx = arrVal.elements.indexOf(elements[matchIdx]); - arrVal.elements[realIdx] = buildKeyframeValueNode(properties, ease); - return recast.print(arrLoc.parsed.ast).code; - } - - const ctx = locateKeyframeCtx(script, animationId, percentage); - if (!ctx) return script; - const { loc, kfNode } = ctx; - - const match = findKeyframePropByPct(kfNode, percentage); - if (!match) return script; - - if (Object.keys(properties).length === 0 && ease) { - // Ease-only update: preserve existing properties, just add/replace ease - const existing = match.prop.value; - if (existing?.type === "ObjectExpression") { - const props = (existing.properties ?? []) as AstNode[]; - const easeIdx = props.findIndex( - (p: AstNode) => isObjectProperty(p) && propKeyName(p) === "ease", - ); - const easeNode = parseExpr(`({ ease: ${JSON.stringify(ease)} })`).properties[0]; - if (easeIdx >= 0) { - props[easeIdx] = easeNode; - } else { - props.push(easeNode); - } - return recast.print(loc.parsed.ast).code; - } - // Non-object keyframe value (primitive shorthand, e.g. "50%": "0.5"): there - // is no property bag to merge the ease into. Rebuilding from empty - // `properties` would wipe the primitive — leave the keyframe untouched. - return script; - } - match.prop.value = buildKeyframeValueNode(properties, ease); - return recast.print(loc.parsed.ast).code; -} - -/** Strip editable properties and ease/keyframes keys from a varsArg. */ -function stripEditableAndEase(varsArg: AstNode): void { - // ease is a BUILTIN_VAR_KEY (not editable), so filterEditableKeys won't remove it — - // drop it explicitly before filtering, along with keyframes. - if (varsArg?.type !== "ObjectExpression") return; - varsArg.properties = varsArg.properties.filter((p: AstNode) => { - if (!isObjectProperty(p)) return true; - const key = propKeyName(p); - return key !== "ease" && key !== "keyframes"; - }); - filterEditableKeys(varsArg, () => false); -} - -/** Build and prepend a keyframes property node onto varsArg. */ -function insertKeyframesProp( - varsArg: AstNode, - fromProps: Record, - toProps: Record, - easeEach?: string, -): void { - const fromEntries = Object.entries(fromProps).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`); - const toEntries = Object.entries(toProps).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`); - const easeEntry = easeEach ? `, easeEach: ${JSON.stringify(easeEach)}` : ""; - const kfCode = `{ "0%": { ${fromEntries.join(", ")} }, "100%": { ${toEntries.join(", ")} }${easeEntry} }`; - const kfProp = parseExpr(`{ keyframes: {} }`).properties[0]; - kfProp.value = parseExpr(kfCode); - if (varsArg?.type === "ObjectExpression") varsArg.properties.unshift(kfProp); -} - -/** - * Convert a flat tween (to/from/fromTo) to percentage-keyframes format. - * `resolvedFromValues` supplies the "from" state for `to()` tweens or - * the "to" state for `from()` tweens (the values the DOM would resolve to). - */ -export function convertToKeyframesInScript( - script: string, - animationId: string, - resolvedFromValues?: Record, - setDuration = 1, -): string { - let loc = locateAnimationWithFallback(script, animationId); - if (!loc) return script; - - const anim = loc.target.animation; - if (anim.keyframes) return script; - - const { fromProps, toProps } = resolveConversionProps(anim, resolvedFromValues); - const varsArg = loc.target.call.varsArg; - const originalEase = anim.ease; - - stripEditableAndEase(varsArg); - insertKeyframesProp(varsArg, fromProps, toProps, originalEase || undefined); - - if (originalEase) { - setVarsKey(varsArg, "ease", "none"); - } - - // For from() or fromTo(), convert to to() - if (anim.method === "from" || anim.method === "fromTo") { - loc.target.call.node.callee.property.name = "to"; - if (anim.method === "fromTo") loc.target.call.node.arguments.splice(1, 1); - } - - // A static `set` becomes an animatable `to`: flip the method, drop the - // immediateRender hold marker, and give it a real duration so the keyframes - // span time. This is what makes a static 3D transform keyframeable. - if (anim.method === "set") { - // A GLOBAL `gsap.set(...)` is off-timeline; flipping only the method would - // emit `gsap.to(...)`, which fires once at load and is NOT on the paused - // master timeline (the engine can't seek/render it). Re-root it onto the - // timeline var and add the position arg (a gsap.set has none) so the - // converted tween is seekable. A `tl.set` already has the right object. - const calleeObj = loc.target.call.node.callee.object; - if (anim.global && calleeObj?.type === "Identifier") { - calleeObj.name = loc.parsed.timelineVar; - if (loc.target.call.node.arguments.length < 3) { - loc.target.call.node.arguments.push(parseExpr("0")); - } - } - loc.target.call.node.callee.property.name = "to"; - removeVarsKey(varsArg, "immediateRender"); - setVarsKey(varsArg, "duration", Math.max(0.001, setDuration)); - } - - return recast.print(loc.parsed.ast).code; -} - -/** - * Remove all keyframes from a tween, collapsing to a flat tween with the - * last keyframe's properties. - */ -export function removeAllKeyframesFromScript(script: string, animationId: string): string { - let loc = locateAnimationWithFallback(script, animationId); - if (!loc) return script; - const kfNode = findKeyframesObjectNode(loc.target.call.varsArg); - if (!kfNode) return script; - - const kfEntries = filterPercentageProps(kfNode) - .map((p: AstNode) => ({ pct: percentageFromKey(propKeyName(p)!), prop: p })) - .filter((e) => !Number.isNaN(e.pct)) - .sort((a, b) => a.pct - b.pct); - if (kfEntries.length === 0) return script; - - // For to()/set(): collapse to last keyframe (the destination = visible state). - // For from(): collapse to first keyframe (the starting state). - const method = loc.target.call.method; - const collapseEntry = method === "from" ? kfEntries[0]! : kfEntries[kfEntries.length - 1]!; - const record = objectExpressionToRecord(collapseEntry.prop.value, loc.parsed.scope); - collapseKeyframesToFlat(loc.target.call.varsArg, record); - - return recast.print(loc.parsed.ast).code; -} - -/** - * Replace a dynamic `keyframes: ` with a static percentage-keyframes object. - * Called when the user first edits a dynamically-generated keyframe in the studio. - */ -export function materializeKeyframesInScript( - script: string, - animationId: string, - keyframes: Array<{ - percentage: number; - properties: Record; - ease?: string; - }>, - easeEach?: string, - resolvedSelector?: string, -): string { - let loc = locateAnimationWithFallback(script, animationId); - if (!loc) return script; - - const varsArg = loc.target.call.varsArg; - - // Replace dynamic selector with resolved static string - if (resolvedSelector && loc.target.call.node.arguments[0]) { - loc.target.call.node.arguments[0] = parseExpr(JSON.stringify(resolvedSelector)); - } - - const kfObjCode = buildKeyframeObjectCode(sortedKeyframes(keyframes), { easeEach }); - const kfParent = varsArg.properties.find( - (p: AstNode) => isObjectProperty(p) && propKeyName(p) === "keyframes", - ); - if (kfParent) { - kfParent.value = parseExpr(kfObjCode); - } else { - const kfProp = parseExpr(`{ keyframes: ${kfObjCode} }`).properties[0]; - varsArg.properties.unshift(kfProp); - } - - removeVarsKey(varsArg, "easeEach"); - - return recast.print(loc.parsed.ast).code; -} - -// ── Arc Path (motionPath) AST Mutations ────────────────────────────────── - -function numericXY(props: Record): { x: number; y: number } | null { - const x = props.x; - const y = props.y; - return typeof x === "number" && typeof y === "number" ? { x, y } : null; -} - -function extractArcWaypoints(anim: GsapAnimation): Array<{ x: number; y: number }> { - const kfs = anim.keyframes?.keyframes ?? []; - const waypoints = kfs.map((kf) => numericXY(kf.properties)).filter((p) => p !== null); - if (waypoints.length >= 2) return waypoints; - const px = anim.properties.x; - const py = anim.properties.y; - if (typeof px !== "number" && typeof py !== "number") return waypoints; - return [ - { x: 0, y: 0 }, - { x: typeof px === "number" ? px : 0, y: typeof py === "number" ? py : 0 }, - ]; -} - -function buildMotionPathObjectCode(config: { - waypoints: Array<{ x: number; y: number }>; - segments: ArcPathSegment[]; - autoRotate: boolean | number; -}): string { - const { waypoints, segments, autoRotate } = config; - const hasExplicitControlPoints = segments.some((s) => s.cp1 && s.cp2); - // The simple `path` array supports only one scalar curviness for the whole - // path, so per-segment curviness must use the cubic form (curviness baked into - // each segment's control points). Without this, the simple branch serializes - // only segments[0].curviness and silently drops every other segment's curve. - const curvinessVaries = segments.some( - (s) => (s.curviness ?? 1) !== (segments[0]?.curviness ?? 1), - ); - - let pathEntries: string[]; - if ((hasExplicitControlPoints || curvinessVaries) && waypoints.length >= 2) { - // type: "cubic" — interleave control points: [anchor, cp1, cp2, anchor, ...] - pathEntries = [`{x: ${waypoints[0]!.x}, y: ${waypoints[0]!.y}}`]; - for (let i = 0; i < segments.length; i++) { - const seg = segments[i]!; - const nextWp = waypoints[i + 1]!; - if (seg.cp1 && seg.cp2) { - pathEntries.push(`{x: ${seg.cp1.x}, y: ${seg.cp1.y}}`); - pathEntries.push(`{x: ${seg.cp2.x}, y: ${seg.cp2.y}}`); - } else { - // Auto-generate simple midpoint control points from curviness - const wp = waypoints[i]!; - const dx = nextWp.x - wp.x; - const dy = nextWp.y - wp.y; - const c = seg.curviness ?? 1; - pathEntries.push( - `{x: ${wp.x + dx * 0.33}, y: ${wp.y + dy * 0.33 - c * Math.abs(dx) * 0.25}}`, - ); - pathEntries.push( - `{x: ${wp.x + dx * 0.66}, y: ${wp.y + dy * 0.66 - c * Math.abs(dx) * 0.25}}`, - ); - } - pathEntries.push(`{x: ${nextWp.x}, y: ${nextWp.y}}`); - } - const pathStr = pathEntries.join(", "); - const parts = [`path: [${pathStr}]`, `type: "cubic"`]; - if (autoRotate === true) parts.push("autoRotate: true"); - else if (typeof autoRotate === "number") parts.push(`autoRotate: ${autoRotate}`); - return `{ ${parts.join(", ")} }`; - } - - // Simple waypoint array with curviness - pathEntries = waypoints.map((wp) => `{x: ${wp.x}, y: ${wp.y}}`); - const curviness = segments[0]?.curviness ?? 1; - const parts = [`path: [${pathEntries.join(", ")}]`]; - if (curviness !== 1) parts.push(`curviness: ${curviness}`); - if (autoRotate === true) parts.push("autoRotate: true"); - else if (typeof autoRotate === "number") parts.push(`autoRotate: ${autoRotate}`); - return `{ ${parts.join(", ")} }`; -} - -export function setArcPathInScript( - script: string, - animationId: string, - config: ArcPathConfig, -): string { - const loc = locateAnimation(script, animationId); - if (!loc) return script; - - const varsArg = loc.target.call.varsArg; - const anim = loc.target.animation; - - if (!config.enabled) { - // Disable arc: restore x/y from motionPath's last waypoint, then remove motionPath - const motionPathProp = varsArg.properties.find( - (p: AstNode) => isObjectProperty(p) && propKeyName(p) === "motionPath", - ); - if (motionPathProp) { - const mpVal = motionPathProp.value; - let pathArr: AstNode[] | undefined; - if (mpVal?.type === "ObjectExpression") { - const pathProp = mpVal.properties.find( - (p: AstNode) => isObjectProperty(p) && propKeyName(p) === "path", - ); - if (pathProp?.value?.type === "ArrayExpression") pathArr = pathProp.value.elements; - } - if (pathArr && pathArr.length > 0) { - const last = pathArr[pathArr.length - 1]; - if (last?.type === "ObjectExpression") { - for (const p of last.properties) { - const k = propKeyName(p); - if (k === "x" || k === "y") { - const v = p.value?.value; - if (typeof v === "number") setVarsKey(varsArg, k, v); - } - } - } - } - } - removeVarsKey(varsArg, "motionPath"); - return recast.print(loc.parsed.ast).code; - } - - const waypoints = extractArcWaypoints(anim); - if (waypoints.length < 2) return script; - - // Build segments — use provided segments or create defaults - const segments: ArcPathSegment[] = - config.segments.length === waypoints.length - 1 - ? config.segments - : Array.from({ length: waypoints.length - 1 }, () => ({ curviness: 1 })); - - const motionPathCode = buildMotionPathObjectCode({ - waypoints, - segments, - autoRotate: config.autoRotate, - }); - - // Set motionPath on the vars - const motionPathNode = parseExpr(motionPathCode); - const existingProp = varsArg.properties.find( - (p: AstNode) => isObjectProperty(p) && propKeyName(p) === "motionPath", - ); - if (existingProp) { - existingProp.value = motionPathNode; - } else { - const prop = parseExpr(`{ motionPath: ${motionPathCode} }`).properties[0]; - varsArg.properties.push(prop); - } - - // Strip x/y from keyframes (they're now in motionPath) - const kfNode = findKeyframesObjectNode(varsArg); - if (kfNode) { - for (const pctProp of filterPercentageProps(kfNode)) { - if (pctProp.value?.type === "ObjectExpression") { - pctProp.value.properties = pctProp.value.properties.filter((p: AstNode) => { - const k = propKeyName(p); - return k !== "x" && k !== "y"; - }); - } - } - } - - // Strip flat x/y from vars (they're now in motionPath) - removeVarsKey(varsArg, "x"); - removeVarsKey(varsArg, "y"); - - return recast.print(loc.parsed.ast).code; -} - -export function updateArcSegmentInScript( - script: string, - animationId: string, - segmentIndex: number, - update: Partial, -): string { - const loc = locateAnimation(script, animationId); - if (!loc) return script; - - const anim = loc.target.animation; - if (!anim.arcPath?.enabled) return script; - - const segments = [...anim.arcPath.segments]; - if (segmentIndex < 0 || segmentIndex >= segments.length) return script; - - segments[segmentIndex] = { ...segments[segmentIndex]!, ...update }; - - const waypoints = extractArcWaypoints(anim); - if (waypoints.length < 2) return script; - - const motionPathCode = buildMotionPathObjectCode({ - waypoints, - segments, - autoRotate: anim.arcPath.autoRotate, - }); - - const varsArg = loc.target.call.varsArg; - const existingProp = varsArg.properties.find( - (p: AstNode) => isObjectProperty(p) && propKeyName(p) === "motionPath", - ); - if (existingProp) { - existingProp.value = parseExpr(motionPathCode); - } - - return recast.print(loc.parsed.ast).code; -} - -/** - * Move a single motionPath waypoint (anchor) to a new position. The waypoint - * list is normalized to anchors for both straight and cubic paths, so - * `pointIndex` matches the node order the studio overlay renders; cubic control - * points are preserved. No-op when the animation/arc is missing or the index is - * out of range. - */ -export function updateMotionPathPointInScript( - script: string, - animationId: string, - pointIndex: number, - point: { x: number; y: number }, -): string { - const loc = locateAnimation(script, animationId); - if (!loc) return script; - - const anim = loc.target.animation; - if (!anim.arcPath?.enabled) return script; - - const waypoints = extractArcWaypoints(anim); - if (pointIndex < 0 || pointIndex >= waypoints.length || waypoints.length < 2) return script; - - const nextWaypoints = waypoints.map((wp, i) => - i === pointIndex ? { x: point.x, y: point.y } : wp, - ); - - const motionPathCode = buildMotionPathObjectCode({ - waypoints: nextWaypoints, - segments: anim.arcPath.segments, - autoRotate: anim.arcPath.autoRotate, - }); - - const varsArg = loc.target.call.varsArg; - const existingProp = varsArg.properties.find( - (p: AstNode) => isObjectProperty(p) && propKeyName(p) === "motionPath", - ); - if (existingProp) { - existingProp.value = parseExpr(motionPathCode); - } - - return recast.print(loc.parsed.ast).code; -} - -/** True when any segment carries explicit cubic control points. Add/remove are - * restricted to curviness (non-cubic) paths — synthesizing control points for - * an inserted cubic anchor is out of scope. */ -function hasCubicSegments(segments: ArcPathSegment[]): boolean { - return segments.some((s) => s.cp1 != null || s.cp2 != null); -} - -function writeMotionPathValue( - loc: NonNullable>, - waypoints: Array<{ x: number; y: number }>, - segments: ArcPathSegment[], - autoRotate: boolean | number, -): string { - const motionPathCode = buildMotionPathObjectCode({ waypoints, segments, autoRotate }); - const varsArg = loc.target.call.varsArg; - const existingProp = varsArg.properties.find( - (p: AstNode) => isObjectProperty(p) && propKeyName(p) === "motionPath", - ); - if (existingProp) existingProp.value = parseExpr(motionPathCode); - return recast.print(loc.parsed.ast).code; -} - -/** - * Insert a waypoint at `index` (between existing anchors), splitting the segment - * it lands on so the new neighbor inherits its curviness. Non-cubic paths only. - * No-op for missing animation/arc, out-of-range index, or cubic paths. - */ -export function addMotionPathPointInScript( - script: string, - animationId: string, - index: number, - point: { x: number; y: number }, -): string { - const loc = locateAnimation(script, animationId); - if (!loc) return script; - const anim = loc.target.animation; - if (!anim.arcPath?.enabled || hasCubicSegments(anim.arcPath.segments)) return script; - - const waypoints = extractArcWaypoints(anim); - // Insert strictly between two anchors: index 1..length-1. - if (index < 1 || index > waypoints.length - 1) return script; - - const segments = [...anim.arcPath.segments]; - waypoints.splice(index, 0, { x: point.x, y: point.y }); - const splitCurviness = segments[index - 1]?.curviness ?? 1; - segments.splice(index - 1, 0, { curviness: splitCurviness }); - - return writeMotionPathValue(loc, waypoints, segments, anim.arcPath.autoRotate); -} - -/** - * Remove the waypoint at `index`. Refuses to drop below two anchors (a path - * can't have fewer). Non-cubic paths only. No-op for missing animation/arc, - * out-of-range index, cubic paths, or a 2-point path. - */ -export function removeMotionPathPointInScript( - script: string, - animationId: string, - index: number, -): string { - const loc = locateAnimation(script, animationId); - if (!loc) return script; - const anim = loc.target.animation; - if (!anim.arcPath?.enabled || hasCubicSegments(anim.arcPath.segments)) return script; - - const waypoints = extractArcWaypoints(anim); - if (waypoints.length <= 2 || index < 0 || index >= waypoints.length) return script; - - const segments = [...anim.arcPath.segments]; - waypoints.splice(index, 1); - // Drop the segment on the side that still exists (last anchor → preceding segment). - segments.splice(Math.min(index, segments.length - 1), 1); - - return writeMotionPathValue(loc, waypoints, segments, anim.arcPath.autoRotate); -} - -/** - * Author a fresh 2-anchor motionPath tween on a target element: a straight line - * from the element's home (0,0) to `point`, gentle ease, ready for waypoint - * editing. Mirrors `addAnimationWithKeyframesToScript`. - */ -export function addMotionPathToScript( - script: string, - targetSelector: string, - position: number, - duration: number, - point: { x: number; y: number }, - ease = "power1.inOut", -): { script: string; id: string | null } { - // `id: null` on the failure paths is a deliberate sentinel: callers must - // null-check before chaining (e.g. locating the new tween). An empty string - // would silently flow into selector/locate calls and match nothing. - let parsed: ParsedGsapAst; - try { - parsed = parseGsapAst(script); - } catch (e) { - console.warn("[gsap-parser] addMotionPathToScript parse failed:", e); - return { script, id: null }; - } - if (parsed.located.length === 0 && parsed.detection.timelineVar === null) { - return { script, id: null }; - } - - const motionPathCode = buildMotionPathObjectCode({ - waypoints: [ - { x: 0, y: 0 }, - { x: point.x, y: point.y }, - ], - segments: [{ curviness: 1 }], - autoRotate: false, - }); - const selector = JSON.stringify(targetSelector); - const varEntries = [ - `motionPath: ${motionPathCode}`, - `duration: ${valueToCode(duration)}`, - `ease: ${JSON.stringify(ease)}`, - ]; - const stmtCode = `${parsed.timelineVar}.to(${selector}, { ${varEntries.join(", ")} }, ${valueToCode(position)});`; - const newStatement = parseScript(stmtCode).program.body[0]; - insertAfterAnchor(parsed, newStatement); - - const result = recast.print(parsed.ast).code; - const reParsed = parseGsapAst(result); - const newId = reParsed.located[reParsed.located.length - 1]?.id ?? null; - return { script: result, id: newId }; -} - -export function removeArcPathFromScript(script: string, animationId: string): string { - return setArcPathInScript(script, animationId, { - enabled: false, - autoRotate: false, - segments: [], - }); -} - -// ── Split Into Property Groups ──────────────────────────────────────────── - -/** - * Split a multi-group tween into separate per-group tweens. Each resulting - * tween contains only properties belonging to one property group (position, - * scale, rotation, visual, etc.). `transformOrigin` stays with the group that - * has the most properties. If the tween already belongs to a single group, - * returns the script unchanged with the original ID. - */ -// fallow-ignore-next-line complexity -export function splitIntoPropertyGroups( - script: string, - animationId: string, -): { script: string; ids: string[] } { - let loc = locateAnimationWithFallback(script, animationId); - if (!loc) return { script, ids: [animationId] }; - - const anim = loc.target.animation; - - // Collect the properties to partition. For keyframed tweens, gather the - // union of all properties across all keyframes. For flat tweens, use the - // tween's own properties map. - const allPropKeys = new Set(); - if (anim.keyframes) { - for (const kf of anim.keyframes.keyframes) { - for (const k of Object.keys(kf.properties)) allPropKeys.add(k); - } - } else { - for (const k of Object.keys(anim.properties)) allPropKeys.add(k); - } - - // Partition properties into groups (excluding transformOrigin — handled below). - const groupProps = new Map(); - for (const key of allPropKeys) { - if (key === "transformOrigin") continue; - const group = classifyPropertyGroup(key); - let arr = groupProps.get(group); - if (!arr) { - arr = []; - groupProps.set(group, arr); - } - arr.push(key); - } - - // Only one group (or zero) — no split needed. - if (groupProps.size <= 1) return { script, ids: [anim.id] }; - - // Assign transformOrigin to the group with the most properties. - if (allPropKeys.has("transformOrigin")) { - let largestGroup: PropertyGroupName | undefined; - let largestCount = 0; - for (const [group, props] of groupProps) { - if (props.length > largestCount) { - largestCount = props.length; - largestGroup = group; - } - } - if (largestGroup) { - groupProps.get(largestGroup)!.push("transformOrigin"); - } - } - - // Build per-group tweens and insert them, then remove the original. - let result = script; - - // Remove the original tween first. - result = removeAnimationFromScript(result, anim.id); - - // Insert one tween per group. Iteration order of the Map follows insertion - // order, which mirrors the order properties were encountered. - for (const [, props] of groupProps) { - const propSet = new Set(props); - - if (anim.keyframes) { - // Build keyframes containing only this group's properties per keyframe. - const groupKeyframes: Array<{ - percentage: number; - properties: Record; - ease?: string; - auto?: boolean; - }> = []; - - for (const kf of anim.keyframes.keyframes) { - const filtered: Record = {}; - for (const [k, v] of Object.entries(kf.properties)) { - if (propSet.has(k)) filtered[k] = v; - } - // Skip keyframes where this group has zero properties. - if (Object.keys(filtered).length === 0) continue; - groupKeyframes.push({ - percentage: kf.percentage, - properties: filtered, - ...(kf.ease ? { ease: kf.ease } : {}), - }); - } - - if (groupKeyframes.length === 0) continue; - - const addResult = addAnimationWithKeyframesToScript( - result, - anim.targetSelector, - typeof anim.position === "number" ? anim.position : 0, - anim.duration ?? 0.5, - groupKeyframes, - anim.keyframes.easeEach ?? anim.ease, - ); - result = addResult.script; - } else { - // Flat tween — filter properties to this group. - const groupProperties: Record = {}; - for (const [k, v] of Object.entries(anim.properties)) { - if (propSet.has(k)) groupProperties[k] = v; - } - if (Object.keys(groupProperties).length === 0) continue; - - let fromProperties: Record | undefined; - if (anim.method === "fromTo" && anim.fromProperties) { - fromProperties = {}; - for (const [k, v] of Object.entries(anim.fromProperties)) { - if (propSet.has(k)) fromProperties[k] = v; - } - } - - const addResult = addAnimationToScript(result, { - targetSelector: anim.targetSelector, - method: anim.method, - position: anim.position, - duration: anim.duration, - ease: anim.ease, - properties: groupProperties, - fromProperties, - extras: anim.extras, - }); - result = addResult.script; - } - } - - // Re-parse to collect the new IDs. - const reParsed = parseGsapAst(result); - const newIds = reParsed.located - .filter((l) => l.animation.targetSelector === anim.targetSelector) - .map((l) => l.id); - - return { script: result, ids: newIds }; -} - -/** - * Replace a dynamic loop that generates multiple tween calls with individual - * static `tl.to()` calls — one per element. Finds the loop containing the - * animation and replaces the entire loop body with unrolled static calls. - */ -export function unrollDynamicAnimations( - script: string, - animationId: string, - elements: Array<{ - selector: string; - keyframes: Array<{ percentage: number; properties: Record }>; - easeEach?: string; - }>, -): string { - const loc = locateAnimation(script, animationId); - if (!loc) return script; - - const varsArg = loc.target.call.varsArg; - - // Read duration and ease from the original tween vars - const durationVal = extractLiteralValue(findPropertyNode(varsArg, "duration"), loc.parsed.scope); - const easeVal = extractLiteralValue(findPropertyNode(varsArg, "ease"), loc.parsed.scope); - const duration = typeof durationVal === "number" ? durationVal : 8; - const ease = typeof easeVal === "string" ? easeVal : "none"; - const posArg = loc.target.call.positionArg; - const position = posArg ? extractLiteralValue(posArg, loc.parsed.scope) : 0; - const posCode = - typeof position === "number" - ? String(position) - : typeof position === "string" - ? JSON.stringify(position) - : "0"; - - // Find the enclosing loop (for/forEach) by walking up the AST path - let loopNode: AstNode | null = null; - let current = loc.target.call.path; - while (current) { - const node = current.node ?? current.value; - if ( - node?.type === "ForStatement" || - node?.type === "ForInStatement" || - node?.type === "ForOfStatement" || - node?.type === "WhileStatement" - ) { - loopNode = node; - break; - } - if ( - node?.type === "ExpressionStatement" && - node.expression?.type === "CallExpression" && - node.expression.callee?.property?.name === "forEach" - ) { - loopNode = node; - break; - } - current = current.parent ?? current.parentPath; - } - - // Build replacement code: individual tl.to() calls for each element - const calls: string[] = []; - for (const el of elements) { - const kfCode = buildKeyframeObjectCode(sortedKeyframes(el.keyframes), { - easeEach: el.easeEach, - }); - calls.push( - `${loc.parsed.timelineVar}.to(${JSON.stringify(el.selector)}, { keyframes: ${kfCode}, duration: ${duration}, ease: ${JSON.stringify(ease)} }, ${posCode});`, - ); - } - - const replacement = calls.join("\n "); - - if (loopNode) { - // Replace the entire loop with the unrolled calls - const start = loopNode.start ?? loopNode.range?.[0]; - const end = loopNode.end ?? loopNode.range?.[1]; - if (typeof start === "number" && typeof end === "number") { - return script.slice(0, start) + replacement + script.slice(end); - } - } - - // Fallback: replace just the tween call's enclosing expression statement - const stmtNode = loc.target.call.path?.parent?.node ?? loc.target.call.path?.parentPath?.node; - if (stmtNode?.type === "ExpressionStatement") { - const start = stmtNode.start ?? stmtNode.range?.[0]; - const end = stmtNode.end ?? stmtNode.range?.[1]; - if (typeof start === "number" && typeof end === "number") { - return script.slice(0, start) + replacement + script.slice(end); - } - } - - return script; -} +// ponytail: compat re-export — moved to @hyperframes/parsers +export * from "@hyperframes/parsers/gsap-parser-recast"; diff --git a/packages/core/src/parsers/gsapParserAcorn.ts b/packages/core/src/parsers/gsapParserAcorn.ts index 38ca4cb8c0..80368edd08 100644 --- a/packages/core/src/parsers/gsapParserAcorn.ts +++ b/packages/core/src/parsers/gsapParserAcorn.ts @@ -1,1231 +1,2 @@ -// 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(["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; -/** Per-scope element bindings: scopeNode → (variable name → selector). */ -type TargetBindings = Map>; - -// ── Value resolution ───────────────────────────────────────────────────────── - -// fallow-ignore-next-line complexity -function resolveNode( - node: any, - scope: ReadonlyMap, -): 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; - } - 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(); - acornWalk.simple(ast, { - VariableDeclarator(node: any) { - const name = node.id?.name; - const init = node.init; - if (name && init) { - const val = resolveNode(init, bindings); - if (val !== 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); - - 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; -} - -// ── 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 { - const result: Record = {}; - 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; -} - -interface TimelineDetection { - timelineVar: string | null; - timelineCount: number; - defaults?: TimelineDefaults; -} - -// 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 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 (!timelineVar) { - timelineVar = node.id?.name ?? null; - defaults = extractTimelineDefaults(node.init, emptyScope); - } - } - }, - AssignmentExpression(node: any) { - if (isGsapTimelineCall(node.right)) { - timelineCount += 1; - if (!timelineVar) { - const left = node.left; - if (left?.type === "Identifier") timelineVar = left.name; - defaults = extractTimelineDefaults(node.right, emptyScope); - } - } - }, - }); - - return { timelineVar, 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 callee chain is rooted at the timeline variable. */ -function isTimelineRootedCall(callNode: any, timelineVar: string): boolean { - let obj = callNode.callee?.object; - while (obj?.type === "CallExpression") { - obj = obj.callee?.object; - } - return obj?.type === "Identifier" && obj.name === timelineVar; -} - -/** - * 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, - timelineVar: string, - 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 a static value round-trips and re-edits in place. - // STRING-LITERAL selectors only: 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, timelineVar) || 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 = {}; - 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; - duration?: number; - ease?: string; - }> = []; - - for (const el of elements) { - if (!el || el.type !== "ObjectExpression") continue; - const record = objectExpressionToRecord(el, scope, source); - const properties: Record = {}; - 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 = 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 = {}; - 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 { - const vars = objectExpressionToRecord(call.varsArg, scope, source); - const properties: Record = {}; - const extras: Record = {}; - 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 | 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 : 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); - } - - const anim: Omit = { - targetSelector: call.selector, - method: call.method, - position, - properties, - fromProperties, - duration, - ease, - }; - if (!hasPositionArg) anim.implicitPosition = true; - let group = classifyTweenPropertyGroup(properties); - if (!group && keyframesData) { - const kfProps: Record = {}; - 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 (call.selector === "__unresolved__") anim.hasUnresolvedSelector = true; - const provenance = readProvenance(call.node); - if (provenance) anim.provenance = provenance; - return anim; -} - -// ── 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; -} - -function applyTimelineDefaults( - anims: Omit[], - 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; - } - } -} - -// fallow-ignore-next-line complexity -function resolveTimelinePositions(anims: Omit[]): void { - let cursor = 0; - let prevStart = 0; - for (const anim of anims) { - // A global `gsap.set(...)` is off-timeline — applied once at load, not - // sequenced on the master timeline. It carries no position arg, so the - // cursor fallback would otherwise hand it the comp-end time. Pin it to 0 - // (its load-time start) and don't advance the cursor/prevStart. - if (anim.method === "set" && anim.global) { - anim.resolvedStart = 0; - continue; - } - const duration = anim.method === "set" ? 0 : (anim.duration ?? GSAP_DEFAULT_DURATION); - let start: number | null; - - if (anim.implicitPosition) { - start = cursor; - } else if (typeof anim.position === "number") { - start = anim.position; - } else if (typeof anim.position === "string") { - start = resolvePositionString(anim.position, cursor, prevStart); - } else { - start = cursor; - } - - if (start != null) { - anim.resolvedStart = Math.max(0, start); - prevStart = anim.resolvedStart; - cursor = Math.max(cursor, anim.resolvedStart + duration); - } - } -} - -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[] { - const counts = new Map(); - 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 timelineVar = detection.timelineVar ?? "tl"; - const calls = findAllTweenCalls(ast, timelineVar, scope, targetBindings); - sortBySourcePosition(calls); - const rawAnims = calls.map((call) => tweenCallToAnimation(call, scope, script)); - applyTimelineDefaults(rawAnims, detection.defaults); - resolveTimelinePositions(rawAnims); - const animations = assignStableIds(rawAnims); - const located = calls.map((call, i) => ({ - id: animations[i]!.id, - call, - animation: animations[i]!, - })); - return { ast, timelineVar, hasTimeline: detection.timelineVar !== 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 timelineVar = detection.timelineVar ?? "tl"; - // 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. - try { - inlineComputedTimelines(ast, timelineVar, (node) => resolveNode(node, scope)); - } catch { - /* fall back to current behavior */ - } - const targetBindings = collectTargetBindings(ast, scope); - const calls = findAllTweenCalls(ast, timelineVar, scope, targetBindings); - sortBySourcePosition(calls); - const rawAnims = calls.map((call) => tweenCallToAnimation(call, scope, script)); - applyTimelineDefaults(rawAnims, detection.defaults); - resolveTimelinePositions(rawAnims); - const animations = assignStableIds(rawAnims); - - const timelineMatch = script.match( - new RegExp( - `^[\\s\\S]*?(?:const|let|var)\\s+${timelineVar}\\s*=\\s*gsap\\.timeline\\s*\\([^)]*\\)\\s*;?`, - ), - ); - const preamble = - timelineMatch?.[0] ?? `const ${timelineVar} = gsap.timeline({ paused: true });`; - - 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.timelineVar === 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 timelineVar = detection.timelineVar ?? "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 tl.addLabel(...) - if ( - callee?.type !== "MemberExpression" || - callee.object?.name !== timelineVar || - 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 []; - } -} +/** @deprecated Import from @hyperframes/parsers/gsap-parser-acorn */ +export * from "@hyperframes/parsers/gsap-parser-acorn"; diff --git a/packages/core/src/parsers/gsapParserExports.ts b/packages/core/src/parsers/gsapParserExports.ts index 5917d13ded..b41e31c511 100644 --- a/packages/core/src/parsers/gsapParserExports.ts +++ b/packages/core/src/parsers/gsapParserExports.ts @@ -1,47 +1,2 @@ -/** - * @hyperframes/core/gsap-parser subpath entry. - * - * Re-exports all public types and helpers that external packages (studio, sdk, - * registry) import via the `@hyperframes/core/gsap-parser` subpath. - * - * The recast-based AST parser (gsapParser.ts) was retired in WS-3.F. The read - * path now uses `parseGsapScriptAcorn` from gsapParserAcorn; the write path - * uses gsapWriterAcorn. This file remains the stable public surface for types - * and serialize helpers. - */ -export type { - GsapAnimation, - GsapMethod, - GsapKeyframesData, - GsapPercentageKeyframe, - ParsedGsap, - ArcPathConfig, - ArcPathSegment, - GsapProvenanceKind, - GsapProvenance, - KeyframeEditability, -} from "./gsapSerialize.js"; -export { - serializeGsapAnimations, - getAnimationsForElementId, - validateCompositionGsap, - keyframesToGsapAnimations, - gsapAnimationsToKeyframes, - editabilityForProvenance, - SUPPORTED_PROPS, - SUPPORTED_EASES, -} from "./gsapSerialize.js"; -// Studio position-hold predicate (`tl.set(...,{data:"hf-hold"})`). A pure -// GsapAnimation helper — re-exported here so studio can filter holds via the -// public entry even though gsapParser.ts is otherwise an internal module. -export { isStudioHoldSet } from "./gsapParser.js"; -export type { PropertyGroupName } from "./gsapConstants.js"; -export { - PROPERTY_GROUPS, - classifyPropertyGroup, - classifyTweenPropertyGroup, -} from "./gsapConstants.js"; -export { generateSpringEaseData, SPRING_PRESETS } from "./springEase.js"; -export type { SpringPreset } from "./springEase.js"; -export { parseGsapScriptAcorn as parseGsapScript } from "./gsapParserAcorn.js"; -export type { SplitAnimationsOptions, SplitAnimationsResult } from "./gsapSerialize.js"; +/** @deprecated Import from @hyperframes/parsers/gsap-parser */ +export * from "@hyperframes/parsers/gsap-parser"; diff --git a/packages/core/src/parsers/gsapSerialize.ts b/packages/core/src/parsers/gsapSerialize.ts index c5832c7156..eb6373671f 100644 --- a/packages/core/src/parsers/gsapSerialize.ts +++ b/packages/core/src/parsers/gsapSerialize.ts @@ -1,604 +1,2 @@ -/** - * Recast-free GSAP helpers: serialization, keyframe<->animation conversion, - * validation, and shared types. - * - * This module MUST NOT import recast / @babel/parser. It is part of the - * isomorphic core layer that the barrel and browser code depend on. AST - * parsing of GSAP source lives in the Node-only `./gsapParser` module. - */ -import type { Keyframe, KeyframeProperties, ValidationResult } from "../core.types"; -import type { PropertyGroupName } from "./gsapConstants"; - -export type GsapMethod = "set" | "to" | "from" | "fromTo"; - -/** How a tween was constructed in source — drives display classification and editability. */ -export type GsapProvenanceKind = "literal" | "helper" | "loop" | "runtime-dynamic"; - -/** - * Origin of a parsed tween. `literal` tweens map 1:1 to a source call and edit - * directly; `helper`/`loop` tweens are expanded from a reused construct (unroll - * to edit); `runtime-dynamic` tweens come from live introspection (override to - * edit). Absent provenance is treated as `literal`. - */ -export interface GsapProvenance { - kind: GsapProvenanceKind; - /** Helper function name (kind === "helper"). */ - fn?: string; - /** 1-based ordinal of the originating call site / loop construct in source order. */ - callSite?: number; - /** 0-based iteration index (kind === "loop"). */ - iteration?: number; - /** Source offset [start, end] of the originating call/loop, when known. */ - sourceRange?: [number, number]; -} - -/** How a tween's keyframes can be edited, derived from its provenance. */ -export type KeyframeEditability = "direct" | "unroll" | "source"; - -/** - * Map provenance to an editing strategy: - * - `direct` — literal tween, maps 1:1 to source; edit in place. - * - `unroll` — helper/loop expansion; unroll to literal tweens, then edit. - * - `source` — runtime-dynamic value; not statically editable, edit the code. - */ -export function editabilityForProvenance(provenance?: GsapProvenance): KeyframeEditability { - if (!provenance || provenance.kind === "literal") return "direct"; - if (provenance.kind === "runtime-dynamic") return "source"; - return "unroll"; -} - -export interface GsapAnimation { - id: string; - targetSelector: string; - method: GsapMethod; - position: number | string; - properties: Record; - fromProperties?: Record; - duration?: number; - ease?: string; - /** Non-editable GSAP config (stagger, yoyo, repeat, etc.) preserved for round-trips. */ - extras?: Record; - /** Native GSAP keyframes data — present when the tween uses keyframes: { ... }. */ - keyframes?: GsapKeyframesData; - /** Arc motion path config — present when the tween uses motionPath for curved position interpolation. */ - arcPath?: ArcPathConfig; - /** True when the tween has a `keyframes` property that couldn't be statically resolved (dynamic). */ - hasUnresolvedKeyframes?: boolean; - /** True when the tween's target selector couldn't be statically resolved (dynamic). */ - hasUnresolvedSelector?: boolean; - /** Absolute start time computed by walking the timeline chain (handles +=, -=, <, >, labels). */ - resolvedStart?: number; - /** True when no position arg was authored — the tween is sequentially placed by GSAP. */ - implicitPosition?: boolean; - /** Which property group this tween belongs to (position, scale, size, rotation, visual, other). - * Undefined for legacy mixed tweens that bundle multiple groups. */ - propertyGroup?: PropertyGroupName; - /** True for a base `gsap.set(...)` (a static hold that runs immediately, OFF the - * timeline) rather than `tl.set(...)`. Carries no timeline position and shows no - * keyframe marker — used to persist a static value (e.g. a 3D transform) without - * introducing a 0% keyframe. */ - global?: boolean; - /** How this tween was constructed in source. Absent ⇒ literal. */ - provenance?: GsapProvenance; -} - -export interface GsapPercentageKeyframe { - percentage: number; - properties: Record; - ease?: string; -} - -export type GsapKeyframeFormat = "percentage" | "object-array" | "simple-array"; - -export interface GsapKeyframesData { - format: GsapKeyframeFormat; - keyframes: GsapPercentageKeyframe[]; - ease?: string; - easeEach?: string; -} - -export interface ArcPathSegment { - curviness: number; - cp1?: { x: number; y: number }; - cp2?: { x: number; y: number }; -} - -export interface ArcPathConfig { - enabled: boolean; - autoRotate: boolean | number; - segments: ArcPathSegment[]; -} - -export interface MotionPathShape { - arcPath: ArcPathConfig; - waypoints: Array<{ x: number; y: number }>; -} - -/** - * Build arcPath segments + waypoints from resolved path coordinates. Shared by - * the AST parser (coords from literal nodes) and the runtime scanner (coords - * from a live `vars.motionPath`), so both produce identical arc config. - */ -export function buildArcPath( - coords: Array<{ x: number; y: number }>, - curviness: number, - autoRotate: boolean | number, - isCubic: boolean, -): MotionPathShape | undefined { - const first = coords[0]; - if (coords.length < 2 || !first) return undefined; - const segments: ArcPathSegment[] = []; - let waypoints: Array<{ x: number; y: number }>; - if (isCubic && coords.length >= 4) { - // coords are [anchor, cp1, cp2, anchor, cp1, cp2, anchor, ...]. - waypoints = [first]; - for (let i = 1; i + 2 < coords.length; i += 3) { - const cp1 = coords[i]; - const cp2 = coords[i + 1]; - const anchor = coords[i + 2]; - if (!cp1 || !cp2 || !anchor) continue; - waypoints.push(anchor); - segments.push({ curviness, cp1, cp2 }); - } - } else { - waypoints = coords; - for (let i = 0; i < waypoints.length - 1; i++) segments.push({ curviness }); - } - return { arcPath: { enabled: true, autoRotate, segments }, waypoints }; -} - -export interface ParsedGsap { - animations: GsapAnimation[]; - timelineVar: string; - preamble: string; - postamble: string; - multipleTimelines?: boolean; - unsupportedTimelinePattern?: boolean; -} - -export { SUPPORTED_PROPS, SUPPORTED_EASES } from "./gsapConstants"; - -// ── Split-animation types (used by gsapWriterAcorn) ───────────────────────── - -export interface SplitAnimationsOptions { - originalId: string; - newId: string; - splitTime: number; - elementStart: number; - elementDuration: number; -} - -export interface SplitAnimationsResult { - script: string; - /** Non-ID-selector animations that the engine cannot safely retarget. */ - skippedSelectors: string[]; -} - -// ── Serialization ─────────────────────────────────────────────────────────── - -export function serializeGsapAnimations( - animations: GsapAnimation[], - timelineVar = "tl", - options?: { includeMediaSync?: boolean; preamble?: string; postamble?: string }, -): string { - const sorted = [...animations].sort((a, b) => { - const aNum = - a.resolvedStart ?? (typeof a.position === "number" ? a.position : Number.MAX_SAFE_INTEGER); - const bNum = - 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 = { ...anim.properties }; - if (anim.duration !== undefined) props.duration = anim.duration; - if (anim.ease) props.ease = anim.ease; - let propsStr = serializeObject(props); - if (anim.extras && Object.keys(anim.extras).length > 0) { - const extrasStr = serializeExtras(anim.extras); - if (Object.keys(props).length === 0) { - propsStr = `{ ${extrasStr} }`; - } else { - // Insert extras before the closing brace - propsStr = propsStr.slice(0, -2) + `, ${extrasStr} }`; - } - } - const posStr = typeof anim.position === "string" ? `"${anim.position}"` : anim.position; - switch (anim.method) { - case "set": - // A global set is a base `gsap.set` — off the timeline, no position arg. - return anim.global - ? ` gsap.set(${selector}, ${propsStr});` - : ` ${timelineVar}.set(${selector}, ${propsStr}, ${posStr});`; - case "to": - return ` ${timelineVar}.to(${selector}, ${propsStr}, ${posStr});`; - case "from": - return ` ${timelineVar}.from(${selector}, ${propsStr}, ${posStr});`; - case "fromTo": { - const fromStr = serializeObject(anim.fromProperties || {}); - return ` ${timelineVar}.fromTo(${selector}, ${fromStr}, ${propsStr}, ${posStr});`; - } - } - }); - - let mediaSync = ""; - if (options?.includeMediaSync) { - mediaSync = ` - ${timelineVar}.eventCallback("onUpdate", function() { - const time = ${timelineVar}.time(); - document.querySelectorAll("video[data-start], audio[data-start]").forEach(function(media) { - const start = parseFloat(media.dataset.start); - const end = parseFloat(media.dataset.end) || Infinity; - const mediaTime = time - start; - if (time >= start && time < end) { - if (Math.abs(media.currentTime - mediaTime) > 0.1) { - media.currentTime = mediaTime; - } - if (media.paused && !${timelineVar}.paused()) { - media.play().catch(function() {}); - } - } else if (!media.paused) { - media.pause(); - } - }); - });`; - } - - const preamble = options?.preamble || `const ${timelineVar} = gsap.timeline({ paused: true });`; - const postamble = options?.postamble ? `\n ${options.postamble}` : ""; - - return ` - ${preamble} -${lines.join("\n")}${mediaSync}${postamble} - `; -} - -export function serializeValue(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); -} - -export function safeJsKey(key: string): string { - return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key) ? key : JSON.stringify(key); -} - -function serializeObject(obj: Record): string { - const entries = Object.entries(obj).map(([key, value]) => { - return `${safeJsKey(key)}: ${serializeValue(value)}`; - }); - return `{ ${entries.join(", ")} }`; -} - -function serializeExtras(extras: Record): string { - return Object.entries(extras) - .map(([key, value]) => { - return `${safeJsKey(key)}: ${serializeValue(value)}`; - }) - .join(", "); -} - -// ── Element filtering ───────────────────────────────────────────────────────── - -/** - * Filter animations to those targeting `#` (id-only match). For the - * studio panel's id-OR-selector matching, see `getAnimationsForElement` in - * `useGsapTweenCache.ts` — distinct on purpose, hence the distinct name. - */ -export function getAnimationsForElementId( - animations: GsapAnimation[], - elementId: string, -): GsapAnimation[] { - const selector = `#${elementId}`; - return animations.filter((a) => a.targetSelector === selector); -} - -// ── Validation (regex-based, no AST needed) ───────────────────────────────── - -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: /\.addPause\s*\(/, message: "addPause() method not allowed" }, - { pattern: /gsap\.registerEffect\s*\(/, message: "registerEffect() not allowed" }, - { pattern: /ScrollTrigger/, message: "ScrollTrigger not allowed" }, - { pattern: /onComplete\s*:/, message: "onComplete callback not allowed" }, - { pattern: /onUpdate\s*:/, message: "onUpdate callback not allowed" }, - { pattern: /onStart\s*:/, message: "onStart callback not allowed" }, - { pattern: /onRepeat\s*:/, message: "onRepeat callback not allowed" }, - { pattern: /onReverseComplete\s*:/, message: "onReverseComplete callback not allowed" }, - { pattern: /repeat\s*:\s*-1/, message: "Infinite repeat (repeat: -1) not allowed" }, - { pattern: /Math\.random\s*\(/, message: "Random values (Math.random) not allowed" }, - { pattern: /Date\.now\s*\(/, message: "Date-dependent values (Date.now) not allowed" }, - { pattern: /new\s+Date\s*\(/, message: "Date constructor not allowed" }, - { pattern: /setTimeout\s*\(/, message: "setTimeout not allowed" }, - { pattern: /setInterval\s*\(/, message: "setInterval not allowed" }, - { pattern: /requestAnimationFrame\s*\(/, message: "requestAnimationFrame not allowed" }, -]; - -export function validateCompositionGsap(script: string): ValidationResult { - const errors: string[] = []; - const warnings: string[] = []; - for (const { pattern, message } of FORBIDDEN_GSAP_PATTERNS) { - if (pattern.test(script)) errors.push(message); - } - if (/yoyo\s*:\s*true/.test(script)) { - warnings.push("yoyo animations may behave unexpectedly when scrubbing"); - } - if (/stagger\s*:/.test(script)) { - warnings.push("stagger animations may not serialize correctly"); - } - return { valid: errors.length === 0, errors, warnings }; -} - -// ── Keyframe Conversion Helpers ───────────────────────────────────────────── - -export function keyframesToGsapAnimations( - elementId: string, - keyframes: Keyframe[], - elementStartTime: number, - base?: { x?: number; y?: number; scale?: number }, -): GsapAnimation[] { - const sorted = [...keyframes].sort((a, b) => a.time - b.time); - const animations: GsapAnimation[] = []; - const baseX = base?.x ?? 0; - 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; - const prevKf = i > 0 ? sorted[i - 1] : null; - const duration = prevKf ? kf.time - prevKf.time : undefined; - const position = prevKf ? elementStartTime + prevKf.time : absoluteTime; - - const properties: Record = {}; - for (const [key, value] of Object.entries(kf.properties)) { - if (typeof value !== "number") continue; - if (key === "x") properties.x = baseX + value; - else if (key === "y") properties.y = baseY + value; - else if (key === "scale") properties.scale = baseScale * value; - else properties[key] = value; - } - - animations.push({ - id: `${elementId}-kf-${kf.id}`, - targetSelector: `#${elementId}`, - method: isFirst ? "set" : "to", - position, - properties, - duration: isFirst ? undefined : duration, - ease: kf.ease, - }); - }); - - return animations; -} - -export function gsapAnimationsToKeyframes( - animations: GsapAnimation[], - elementStartTime: number, - options?: { - baseX?: number; - baseY?: number; - baseScale?: number; - clampTimeToZero?: boolean; - skipBaseSet?: boolean; - }, -): Keyframe[] { - const validMethods: GsapMethod[] = ["set", "to", "from", "fromTo"]; - const baseX = options?.baseX ?? 0; - const baseY = options?.baseY ?? 0; - const baseScale = options?.baseScale ?? 1; - const clampTimeToZero = options?.clampTimeToZero ?? true; - const skipBaseSet = options?.skipBaseSet ?? false; - const baseTimeEpsilon = 0.001; - const baseValueEpsilon = 0.00001; - - return ( - animations - .filter( - (a): a is GsapAnimation & { position: number } => - validMethods.includes(a.method) && typeof a.position === "number", - ) - // fallow-ignore-next-line complexity - .map((a) => { - const relativeTimeRaw = a.position - elementStartTime; - const time = clampTimeToZero ? Math.max(0, relativeTimeRaw) : relativeTimeRaw; - - const properties: Partial = {}; - for (const [key, value] of Object.entries(a.properties)) { - if (typeof value !== "number") continue; - if (key === "x") properties.x = value - baseX; - else if (key === "y") properties.y = value - baseY; - else if (key === "scale") { - properties.scale = baseScale !== 0 ? value / baseScale : value; - } else { - (properties as Record)[key] = value; - } - } - - if ( - skipBaseSet && - a.method === "set" && - time < baseTimeEpsilon && - Object.values(properties).every( - (v) => typeof v === "number" && Math.abs(v) < baseValueEpsilon, - ) - ) { - return null; - } - - return { - id: a.id.replace(/^.*-kf-/, ""), - time, - properties: properties as KeyframeProperties, - ease: a.ease, - }; - }) - .filter((kf): kf is NonNullable => kf !== null) - ); -} - -// ── Keyframe-conversion transforms (pure; shared by recast + acorn writers) ──── - -/** - * CSS identity values for properties whose "rest" state isn't 0 — used to - * synthesize the missing endpoint when converting a flat tween to keyframes. - */ -const CSS_IDENTITY: Record = { - opacity: 1, - autoAlpha: 1, - scale: 1, - scaleX: 1, - scaleY: 1, -}; - -function cssIdentityValue(prop: string): number { - return CSS_IDENTITY[prop] ?? 0; -} - -/** Build the identity-endpoint map for a flat tween's properties. */ -function buildIdentityMap(props: Record): Record { - const identity: Record = {}; - for (const [key, val] of Object.entries(props)) { - if (val != null) identity[key] = typeof val === "number" ? cssIdentityValue(key) : val; - } - return identity; -} - -/** - * Resolve the 0% (from) and 100% (to) property maps for a tween being - * converted to percentage keyframes. - * - * @param resolvedFromValues — Despite the "from" in the name (historical), these - * are runtime-captured DOM values that override the conversion endpoint: - * - For to(): overrides fromProps (the 0% state / where the element is now). - * - For from(): overrides toProps (the 100% state / where the element rests). - * - For fromTo(): merges into toProps (the 100% endpoint the user is editing). - */ -export function resolveConversionProps( - anim: GsapAnimation, - resolvedFromValues?: Record, -): { fromProps: Record; toProps: Record } { - if (anim.method === "set") { - // A static hold becomes a keyframed `to` whose 0% and 100% both start at the - // set's value — the visual is unchanged until the user edits a keyframe to - // animate it. (The caller flips the call from `set` to `to` + adds a duration.) - return { fromProps: { ...anim.properties }, toProps: { ...anim.properties } }; - } - if (anim.method === "to") { - const identity = buildIdentityMap(anim.properties); - const fromProps = resolvedFromValues ? { ...identity, ...resolvedFromValues } : identity; - return { fromProps, toProps: { ...anim.properties } }; - } - if (anim.method === "from") { - const identity = buildIdentityMap(anim.properties); - const toProps = resolvedFromValues ? { ...identity, ...resolvedFromValues } : identity; - return { fromProps: { ...anim.properties }, toProps }; - } - // fromTo(fromVars, toVars): anim.fromProperties = fromVars (0% state), - // anim.properties = toVars (100% state). resolvedFromValues contains the - // current DOM position from a drag — it represents the NEW destination, so - // it merges into toProps (the 100% endpoint the user is editing), NOT into - // fromProps. This is intentional and not inverted. - const toProps = resolvedFromValues - ? { ...anim.properties, ...resolvedFromValues } - : { ...anim.properties }; - return { fromProps: { ...(anim.fromProperties ?? {}) }, toProps }; -} - -// ── Arc path serialization helpers (shared by recast + acorn writers) ───────── - -function numericXY(props: Record): { x: number; y: number } | null { - const vx = props.x; - const vy = props.y; - return typeof vx === "number" && typeof vy === "number" ? { x: vx, y: vy } : null; -} - -export function extractArcWaypoints(anim: GsapAnimation): Array<{ x: number; y: number }> { - const keyframeWps = (anim.keyframes?.keyframes ?? []) - .map((kf) => numericXY(kf.properties)) - .filter((pt): pt is { x: number; y: number } => pt !== null); - if (keyframeWps.length >= 2) return keyframeWps; - const propX = anim.properties.x; - const propY = anim.properties.y; - if (typeof propX !== "number" && typeof propY !== "number") return keyframeWps; - const destX = typeof propX === "number" ? propX : 0; - const destY = typeof propY === "number" ? propY : 0; - return [ - { x: 0, y: 0 }, - { x: destX, y: destY }, - ]; -} - -function autoRotateSuffix(autoRotate: boolean | number): string { - if (autoRotate === true) return ", autoRotate: true"; - if (typeof autoRotate === "number") return `, autoRotate: ${autoRotate}`; - return ""; -} - -function cubicControlPoints( - seg: ArcPathSegment, - wp: { x: number; y: number }, - nextWp: { x: number; y: number }, -): string[] { - if (seg.cp1 && seg.cp2) { - return [`{x: ${seg.cp1.x}, y: ${seg.cp1.y}}`, `{x: ${seg.cp2.x}, y: ${seg.cp2.y}}`]; - } - const dx = nextWp.x - wp.x; - const dy = nextWp.y - wp.y; - const c = seg.curviness ?? 1; - return [ - `{x: ${wp.x + dx * 0.33}, y: ${wp.y + dy * 0.33 - c * Math.abs(dx) * 0.25}}`, - `{x: ${wp.x + dx * 0.66}, y: ${wp.y + dy * 0.66 - c * Math.abs(dx) * 0.25}}`, - ]; -} - -function buildCubicPathEntries( - waypoints: Array<{ x: number; y: number }>, - segments: ArcPathSegment[], -): string[] { - const first = waypoints[0]; - if (!first) return []; - const entries = [`{x: ${first.x}, y: ${first.y}}`]; - for (let i = 0; i < segments.length; i++) { - const seg = segments[i]; - const wp = waypoints[i]; - const nextWp = waypoints[i + 1]; - if (!seg || !wp || !nextWp) continue; - entries.push(...cubicControlPoints(seg, wp, nextWp)); - entries.push(`{x: ${nextWp.x}, y: ${nextWp.y}}`); - } - return entries; -} - -export function buildMotionPathObjectCode(config: { - waypoints: Array<{ x: number; y: number }>; - segments: ArcPathSegment[]; - autoRotate: boolean | number; -}): string { - const { waypoints, segments, autoRotate } = config; - const arSuffix = autoRotateSuffix(autoRotate); - // GSAP's simple `path` array supports only ONE scalar `curviness` for the whole - // path, so per-segment curviness can only be expressed in the cubic form (each - // segment's curviness baked into its control points). Emit cubic when segments - // carry explicit control points OR when their curviness values differ — the - // simple branch would otherwise serialize only segments[0].curviness and drop - // every other segment's curve. - const hasExplicitCp = segments.some((s) => s.cp1 && s.cp2); - const curvinessVaries = segments.some( - (s) => (s.curviness ?? 1) !== (segments[0]?.curviness ?? 1), - ); - if ((hasExplicitCp || curvinessVaries) && waypoints.length >= 2) { - const pathStr = buildCubicPathEntries(waypoints, segments).join(", "); - return `{ path: [${pathStr}], type: "cubic"${arSuffix} }`; - } - const pathEntries = waypoints.map((wp) => `{x: ${wp.x}, y: ${wp.y}}`); - const curviness = segments[0]?.curviness ?? 1; - const curvPart = curviness !== 1 ? `, curviness: ${curviness}` : ""; - return `{ path: [${pathEntries.join(", ")}]${curvPart}${arSuffix} }`; -} +// ponytail: compat re-export — moved to @hyperframes/parsers +export * from "@hyperframes/parsers/gsap-parser"; diff --git a/packages/core/src/parsers/gsapUnroll.ts b/packages/core/src/parsers/gsapUnroll.ts index 5506e757c2..5e6d758598 100644 --- a/packages/core/src/parsers/gsapUnroll.ts +++ b/packages/core/src/parsers/gsapUnroll.ts @@ -1,143 +1,2 @@ -/** - * Unroll computed GSAP timelines (helpers / bounded loops) into explicit literal - * tweens — the source-rewrite behind the Studio "Unroll to edit" action. - * - * Strategy: the read parser already resolves each computed tween (positions, - * motionPath arcs, keyframes, provenance). We serialize those resolved - * animations back to literal `tl.*` statements and surgically replace the - * top-level helper-call / loop statements that produced them (and drop the now - * dead helper declarations) via magic-string, leaving the rest of the source — - * literal tweens, comments, formatting — untouched. The result is a visual - * no-op: re-parsing it yields the same animations, now all literal. - * - * Scope: top-level helper calls and loops (the common authoring shape). Tweens - * whose origin can't be mapped to a top-level statement (e.g. helpers nested - * inside other helpers) are left as-is rather than guessed at. - */ -import * as acorn from "acorn"; -import MagicString from "magic-string"; -import type { GsapAnimation } from "./gsapSerialize.js"; -import { serializeValue as valueToCode, safeJsKey as safeKey } from "./gsapSerialize.js"; -import { parseGsapScriptAcorn } from "./gsapParserAcorn.js"; - -// acorn nodes are structurally untyped here. -type Node = any; - -function propEntries(props: Record): string[] { - return Object.entries(props).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`); -} - -function motionPathEntry(anim: GsapAnimation): string { - const waypoints = (anim.keyframes?.keyframes ?? []) - .filter((k) => typeof k.properties.x === "number" && typeof k.properties.y === "number") - .map((k) => `{ x: ${valueToCode(k.properties.x!)}, y: ${valueToCode(k.properties.y!)} }`); - const curviness = anim.arcPath?.segments[0]?.curviness ?? 1; - const autoRotate = anim.arcPath?.autoRotate; - const extra = autoRotate ? `, autoRotate: ${valueToCode(autoRotate as number | string)}` : ""; - return `motionPath: { path: [${waypoints.join(", ")}], curviness: ${curviness}${extra} }`; -} - -function keyframesEntry(anim: GsapAnimation): string { - const kfs = (anim.keyframes?.keyframes ?? []).map((k) => { - const body = propEntries(k.properties); - if (k.ease) body.push(`ease: ${valueToCode(k.ease)}`); - return `"${k.percentage}%": { ${body.join(", ")} }`; - }); - if (anim.keyframes?.easeEach) kfs.push(`easeEach: ${valueToCode(anim.keyframes.easeEach)}`); - return `keyframes: { ${kfs.join(", ")} }`; -} - -/** The vars-object entries for a tween: motionPath/keyframes block, props, duration, ease, extras. */ -function buildVarsParts(anim: GsapAnimation): string[] { - const parts: string[] = []; - if (anim.arcPath?.enabled) parts.push(motionPathEntry(anim)); - else if (anim.keyframes) parts.push(keyframesEntry(anim)); - parts.push(...propEntries(anim.properties)); - if (anim.method !== "set" && anim.duration !== undefined) { - parts.push(`duration: ${valueToCode(anim.duration)}`); - } - if (anim.ease) parts.push(`ease: ${valueToCode(anim.ease)}`); - for (const [k, v] of Object.entries(anim.extras ?? {})) { - parts.push(`${safeKey(k)}: ${valueToCode(v as number | string)}`); - } - return parts; -} - -/** Serialize one resolved animation to a literal `tl.*` statement (arc/keyframe-aware). */ -function serializeTweenStatement(timelineVar: string, anim: GsapAnimation): string { - const obj = `{ ${buildVarsParts(anim).join(", ")} }`; - const pos = valueToCode( - anim.resolvedStart ?? (typeof anim.position === "number" ? anim.position : 0), - ); - const sel = valueToCode(anim.targetSelector); - if (anim.method === "fromTo") { - const from = `{ ${propEntries(anim.fromProperties ?? {}).join(", ")} }`; - return `${timelineVar}.fromTo(${sel}, ${from}, ${obj}, ${pos});`; - } - return `${timelineVar}.${anim.method}(${sel}, ${obj}, ${pos});`; -} - -/** A computed animation is one expanded from a helper or loop (not literal/dynamic). */ -function isComputed(anim: GsapAnimation): boolean { - return anim.provenance?.kind === "helper" || anim.provenance?.kind === "loop"; -} - -/** Top-level statements of the parsed program. */ -function topLevelStatements(script: string): Node[] { - return acorn.parse(script, { ecmaVersion: "latest", sourceType: "script" }).body ?? []; -} - -/** The top-level statement whose source span contains [start, end], or null. */ -function enclosingTopLevel(statements: Node[], start: number, end: number): Node | null { - for (const stmt of statements) { - if (stmt.start <= start && stmt.end >= end) return stmt; - } - return null; -} - -function isHelperDeclNamed(stmt: Node, names: Set): boolean { - if (stmt.type === "FunctionDeclaration") return names.has(stmt.id?.name); - if (stmt.type === "VariableDeclaration") { - return (stmt.declarations ?? []).some((d: Node) => names.has(d.id?.name)); - } - return false; -} - -/** - * Rewrite `script` so top-level helper calls / loops that build the timeline - * become explicit literal tweens. Returns the original script unchanged when - * there is nothing statically-resolvable to unroll. - */ -export function unrollComputedTimeline(script: string): string { - const parsed = parseGsapScriptAcorn(script); - const computed = parsed.animations.filter((a) => isComputed(a) && a.provenance?.sourceRange); - if (computed.length === 0) return script; - - const statements = topLevelStatements(script); - - // Group computed animations by the top-level statement that produced them, - // preserving source order within each group. - const byStatement = new Map(); - const helperNames = new Set(); - for (const anim of computed) { - if (anim.provenance?.fn) helperNames.add(anim.provenance.fn); - const [s, e] = anim.provenance!.sourceRange!; - const stmt = enclosingTopLevel(statements, s, e); - if (!stmt) continue; // nested origin — leave it; can't map to a top-level edit - const list = byStatement.get(stmt) ?? []; - list.push(anim); - byStatement.set(stmt, list); - } - if (byStatement.size === 0) return script; - - const ms = new MagicString(script); - for (const [stmt, anims] of byStatement) { - const literals = anims.map((a) => serializeTweenStatement(parsed.timelineVar, a)).join("\n"); - ms.overwrite(stmt.start, stmt.end, literals); - } - // Drop the now-dead helper declarations. - for (const stmt of statements) { - if (isHelperDeclNamed(stmt, helperNames)) ms.remove(stmt.start, stmt.end); - } - return ms.toString(); -} +// ponytail: compat re-export — moved to @hyperframes/parsers +export { unrollComputedTimeline } from "@hyperframes/parsers"; diff --git a/packages/core/src/parsers/gsapWriterAcorn.ts b/packages/core/src/parsers/gsapWriterAcorn.ts index 3d9bf70027..908d10fa77 100644 --- a/packages/core/src/parsers/gsapWriterAcorn.ts +++ b/packages/core/src/parsers/gsapWriterAcorn.ts @@ -1,2375 +1,2 @@ -// fallow-ignore-file code-duplication -/** - * Browser-safe GSAP write path — magic-string offset-splice. - * - * T6c: edits GSAP scripts by overwriting/removing byte ranges in the original - * source. Every byte outside the edited span is preserved verbatim — no - * pretty-printer churn. Consumes ParsedGsapAcornForWrite from gsapParserAcorn.ts. - */ -import MagicString from "magic-string"; -import type { - GsapAnimation, - GsapPercentageKeyframe, - ArcPathConfig, - ArcPathSegment, -} from "./gsapSerialize.js"; -import { - resolveConversionProps, - extractArcWaypoints, - buildMotionPathObjectCode, -} from "./gsapSerialize.js"; -import { - parseGsapScriptAcornForWrite, - type ParsedGsapAcornForWrite, - type TweenCallInfo, -} from "./gsapParserAcorn.js"; -import { classifyPropertyGroup } from "./gsapConstants.js"; -import type { PropertyGroupName } from "./gsapConstants.js"; -import type { SplitAnimationsOptions, SplitAnimationsResult } from "./gsapSerialize.js"; -import * as acornWalk from "acorn-walk"; - -// acorn ESTree nodes are structurally untyped here; mirror gsapParserAcorn.ts / -// gsapInline.ts rather than re-deriving the full ESTree union for every access. -type Node = any; - -// ── Code generation helpers ────────────────────────────────────────────────── - -// Local serializer for the tween-statement path, which may carry boolean/object -// extras (stagger config). serializeValue stringifies objects to "[object -// Object]", so keep this richer JSON fallback for that path. Keyframe values are -// always number|string and use the shared serializeValue (recast parity). -function valueToCode(value: unknown): string { - if (typeof value === "string" && value.startsWith("__raw:")) return value.slice(6); - if (typeof value === "string") return JSON.stringify(value); - if (typeof value === "number") return Number.isNaN(value) ? "0" : String(value); - if (typeof value === "boolean") return String(value); - return JSON.stringify(value); -} - -function safeKey(key: string): string { - return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key) ? key : JSON.stringify(key); -} - -// fallow-ignore-next-line complexity -function buildTweenStatementCode(timelineVar: string, anim: Omit): string { - const selector = JSON.stringify(anim.targetSelector); - const props: Record = { ...anim.properties }; - if (anim.method !== "set" && anim.duration !== undefined) props.duration = anim.duration; - if (anim.ease) props.ease = anim.ease; - const entries = Object.entries(props).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`); - if (anim.extras) { - for (const [k, v] of Object.entries(anim.extras)) { - entries.push(`${safeKey(k)}: ${valueToCode(v)}`); - } - } - const objCode = `{ ${entries.join(", ")} }`; - const posCode = valueToCode( - typeof anim.position === "number" ? anim.position : (anim.position ?? 0), - ); - if (anim.method === "fromTo") { - const fromEntries = Object.entries(anim.fromProperties ?? {}).map( - ([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`, - ); - return `${timelineVar}.fromTo(${selector}, { ${fromEntries.join(", ")} }, ${objCode}, ${posCode});`; - } - // A base `gsap.set` is off the timeline: no timeline var, no position arg. - if (anim.method === "set" && anim.global) { - return `gsap.set(${selector}, ${objCode});`; - } - return `${timelineVar}.${anim.method}(${selector}, ${objCode}, ${posCode});`; -} - -// ── AST node helpers ───────────────────────────────────────────────────────── - -function isObjectProperty(prop: Node): boolean { - return prop?.type === "ObjectProperty" || prop?.type === "Property"; -} - -function propKeyName(prop: Node): string | undefined { - return prop?.key?.name ?? prop?.key?.value; -} - -function findPropertyNode(varsArgNode: Node, key: string): Node | undefined { - if (varsArgNode?.type !== "ObjectExpression") return undefined; - for (const prop of varsArgNode.properties ?? []) { - if (!isObjectProperty(prop)) continue; - if (propKeyName(prop) === key) return prop; - } - return undefined; -} - -/** The `keyframes` property's ObjectExpression value, or null when not a keyframe tween. */ -function keyframesObjectNode(varsNode: Node): Node | null { - const kfProp = findPropertyNode(varsNode, "keyframes"); - return kfProp?.value?.type === "ObjectExpression" ? kfProp.value : null; -} - -function findEnclosingExpressionStatement(ancestors: Node[]): Node | null { - for (let i = ancestors.length - 2; i >= 0; i--) { - if (ancestors[i]?.type === "ExpressionStatement") return ancestors[i]; - } - return null; -} - -/** Find the VariableDeclaration statement for `tl = gsap.timeline(...)`. */ -function findTimelineDeclarationStatement(ast: Node, timelineVar: string): Node | null { - let found: Node = null; - acornWalk.simple(ast, { - // fallow-ignore-next-line complexity - VariableDeclaration(node: Node) { - if (found) return; - for (const decl of node.declarations ?? []) { - if ( - decl.id?.name === timelineVar && - decl.init?.type === "CallExpression" && - decl.init.callee?.type === "MemberExpression" && - decl.init.callee.object?.name === "gsap" && - decl.init.callee.property?.name === "timeline" - ) { - found = node; - } - } - }, - }); - return found; -} - -// ── Property splice helpers ─────────────────────────────────────────────────── - -/** - * Remove a property from a properties array, handling its comma. - * `editableProps` must be the isObjectProperty-filtered subset in source order. - */ -function removeProp(ms: MagicString, propNode: Node, editableProps: Node[]): void { - const idx = editableProps.indexOf(propNode); - if (idx === -1) return; - if (editableProps.length === 1) { - ms.remove(propNode.start, propNode.end); - } else if (idx === 0) { - // First prop: remove from its start to next prop start (drops trailing ", ") - ms.remove(editableProps[0].start, editableProps[1].start); - } else { - // Non-first: remove from prev prop end to this prop end (drops leading ", ") - ms.remove(editableProps[idx - 1].end, propNode.end); - } -} - -/** Serialize a vars record to an object-literal source: `{ k: v, ... }`. */ -function buildVarsObjectCode(record: Record): string { - const entries = Object.entries(record).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`); - return entries.length > 0 ? `{ ${entries.join(", ")} }` : "{}"; -} - -/** Overwrite a tween call's vars ObjectExpression with freshly-built source. */ -function overwriteVarsArg(ms: MagicString, call: TweenCallInfo, objCode: string): void { - if (!call.varsArg) return; - ms.overwrite(call.varsArg.start, call.varsArg.end, objCode); -} - -/** - * 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: Node, key: string, value: unknown): void { - if (objNode?.type !== "ObjectExpression") return; - const existing = findPropertyNode(objNode, key); - if (existing) { - ms.overwrite(existing.value.start, existing.value.end, valueToCode(value)); - } else { - const sep = objNode.properties.length > 0 ? ", " : ""; - ms.appendLeft(objNode.end - 1, `${sep}${safeKey(key)}: ${valueToCode(value)}`); - } -} - -/** - * Vars keys that are NOT editable transform/style props: builtins - * (duration/ease/delay), dropped callbacks, and extras (stagger/yoyo/repeat/…). - * The exact union of recast's BUILTIN_VAR_KEYS + DROPPED_VAR_KEYS + EXTRAS_KEYS, - * so both writers classify vars keys identically. (Distinct from the keyframe- - * conversion NON_EDITABLE_VAR_KEYS below, which intentionally omits `ease` - * because that path re-emits ease separately.) - */ -const NON_EDITABLE_PROP_KEYS = new Set([ - "duration", - "ease", - "delay", - "onComplete", - "onStart", - "onUpdate", - "onRepeat", - "stagger", - "yoyo", - "repeat", - "repeatDelay", - "snap", - "overwrite", - "immediateRender", -]); - -/** - * Editable transform/style key test: anything NOT a builtin, dropped callback, or - * extras key. Mirrors recast's isEditablePropertyKey so both writers classify - * vars keys identically. - */ -function isEditableVarKey(key: string): boolean { - return !NON_EDITABLE_PROP_KEYS.has(key); -} - -/** - * Collect verbatim `key: value` entries to PRESERVE from a vars/keyframe - * ObjectExpression: every property whose key `drop` does not reject, sliced from - * source — except keys present in `overrides`, whose value is replaced. Returns - * the entries plus the set of keys it kept, so callers can append new keys. - */ -function preservedEntries( - objNode: Node, - source: string, - drop: (key: string) => boolean, - overrides: Record, -): { entries: string[]; keys: Set } { - const entries: string[] = []; - const keys = new Set(); - for (const prop of objNode.properties ?? []) { - if (!isObjectProperty(prop)) continue; - const key = propKeyName(prop); - if (typeof key !== "string" || drop(key)) continue; - keys.add(key); - const code = - key in overrides - ? valueToCode(overrides[key]) - : source.slice(prop.value.start, prop.value.end); - entries.push(`${safeKey(key)}: ${code}`); - } - return { entries, keys }; -} - -/** - * Replace the editable-property keys on a vars ObjectExpression with exactly - * `newProps`, leaving non-editable keys (duration/ease/stagger/callbacks/…) - * untouched unless overridden in `nonEditableOverrides`. Mirrors recast's - * reconcileEditableProperties: editable keys absent from `newProps` are DROPPED, - * not merged. Rebuilt in a single ms.overwrite so the splice can never overlap a - * sibling edit — non-editable updates that also target this node (duration/ease/ - * extras) are folded into the same rebuild rather than spliced separately. - */ -function reconcileEditableProps( - ms: MagicString, - objNode: Node, - source: string, - newProps: Record, - nonEditableOverrides?: Record, -): void { - if (objNode?.type !== "ObjectExpression") return; - const overrides = nonEditableOverrides ?? {}; - const { entries, keys } = preservedEntries(objNode, source, isEditableVarKey, overrides); - for (const [key, value] of Object.entries(overrides)) { - if (!keys.has(key)) entries.push(`${safeKey(key)}: ${valueToCode(value)}`); - } - for (const [key, value] of Object.entries(newProps)) { - entries.push(`${safeKey(key)}: ${valueToCode(value)}`); - } - ms.overwrite(objNode.start, objNode.end, `{ ${entries.join(", ")} }`); -} - -// ── Insertion helpers ───────────────────────────────────────────────────────── - -/** Traverse callee.object chain to check if a call ultimately roots at timelineVar. */ -function isTimelineRooted(node: Node, 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 { - const lastLocated = parsed.located[parsed.located.length - 1]; - if (lastLocated) { - const lastCall = lastLocated.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 -export function updateAnimationInScript( - script: string, - animationId: string, - updates: Partial & { easeEach?: string; resetKeyframeEases?: boolean }, -): string { - if (!Object.keys(updates).length) return script; - const parsed = parseGsapScriptAcornForWrite(script); - if (!parsed) return script; - const target = parsed.located.find((l) => l.id === animationId); - if (!target) return script; - - const ms = new MagicString(script); - const { call }: { call: TweenCallInfo } = target; - - // When `properties` is present we REPLACE the editable set (recast parity: - // editable keys absent from the update are dropped). Fold any concurrent - // non-editable updates (duration/ease/extras) into the single varsArg rebuild - // so their splices can't overlap the rebuild's overwrite of the whole node. - if (updates.properties) { - const overrides: Record = {}; - if (updates.duration !== undefined) overrides.duration = updates.duration; - if (updates.ease !== undefined) overrides.ease = updates.ease; - if (updates.extras) Object.assign(overrides, updates.extras); - reconcileEditableProps(ms, call.varsArg, script, updates.properties, overrides); - } else { - if (updates.duration !== undefined) { - upsertProp(ms, call.varsArg, "duration", updates.duration); - } - const easeValue = updates.easeEach ?? updates.ease; - if (easeValue !== undefined) { - const kfNode = keyframesObjectNode(call.varsArg); - if (kfNode) { - upsertProp(ms, kfNode, "easeEach", easeValue); - // "Apply to all segments": drop every per-keyframe `ease` override so the - // single easeEach governs all segments uniformly (AE select-all + F9). - if (updates.resetKeyframeEases) { - for (const kfEntry of kfNode.properties ?? []) { - if (!isObjectProperty(kfEntry)) continue; - const val = kfEntry.value; - if (val?.type !== "ObjectExpression") continue; - const easeNode = findPropertyNode(val, "ease"); - if (easeNode) removeProp(ms, easeNode, val.properties); - } - } - } else { - upsertProp(ms, call.varsArg, "ease", easeValue); - } - } - if (updates.extras) { - for (const [key, value] of Object.entries(updates.extras)) { - upsertProp(ms, call.varsArg, key, value); - } - } - } - - if (updates.fromProperties && call.method === "fromTo" && call.fromArg) { - // fromTo's from-vars carry only editable props — REPLACE them too (recast - // parity). fromArg is a distinct node from varsArg, so this rebuild never - // overlaps the varsArg edits above. - reconcileEditableProps(ms, call.fromArg, script, updates.fromProperties); - } - - if (updates.position !== undefined) { - overwritePosition(ms, call, updates.position); - } - - return ms.toString(); -} - -/** - * Overwrite a tween call's numeric position argument (the positionArg the parser - * located: 3rd arg for fromTo, else 2nd), or append one when the call has no - * explicit position. Shared by updateAnimationInScript and the - * shift/scalePositionsInScript timeline ops. - */ -function overwritePosition(ms: MagicString, call: TweenCallInfo, position: number | string): void { - if (call.positionArg) { - ms.overwrite(call.positionArg.start, call.positionArg.end, valueToCode(position)); - } else { - ms.appendLeft(call.node.end - 1, `, ${valueToCode(position)}`); - } -} - -/** - * Shift every tween targeting `targetSelector` by `delta` seconds (clamped ≥0), - * rewriting each call's position argument. Mirrors recast's shiftPositionsInScript - * (used by timeline clip-move to keep GSAP positions in sync with the clip start). - */ -export function shiftPositionsInScript( - script: string, - targetSelector: string, - delta: number, -): string { - const parsed = parseGsapScriptAcornForWrite(script); - if (!parsed) return script; - const ms = new MagicString(script); - let changed = false; - for (const entry of parsed.located) { - if (entry.animation.targetSelector !== targetSelector) continue; - if (typeof entry.animation.position !== "number") continue; - const newPos = Math.max(0, Math.round((entry.animation.position + delta) * 1000) / 1000); - overwritePosition(ms, entry.call, newPos); - changed = true; - } - return changed ? ms.toString() : script; -} - -/** - * Linearly remap every tween targeting `targetSelector` from the old clip - * [oldStart, oldDuration] onto the new [newStart, newDuration] (position and, - * when present, duration scaled by the duration ratio). Mirrors recast's - * scalePositionsInScript (used by timeline clip-resize). - */ -export function scalePositionsInScript( - script: string, - targetSelector: string, - oldStart: number, - oldDuration: number, - newStart: number, - newDuration: number, -): string { - if (oldDuration <= 0 || newDuration <= 0) return script; - const ratio = newDuration / oldDuration; - const parsed = parseGsapScriptAcornForWrite(script); - if (!parsed) return script; - const ms = new MagicString(script); - let changed = false; - for (const entry of parsed.located) { - if (entry.animation.targetSelector !== targetSelector) continue; - if (typeof entry.animation.position !== "number") continue; - const newPos = Math.max( - 0, - Math.round((newStart + (entry.animation.position - oldStart) * ratio) * 1000) / 1000, - ); - overwritePosition(ms, entry.call, newPos); - if (typeof entry.animation.duration === "number" && entry.animation.duration > 0) { - const newDur = Math.max(0.001, Math.round(entry.animation.duration * ratio * 1000) / 1000); - upsertProp(ms, entry.call.varsArg, "duration", newDur); - } - changed = true; - } - return changed ? ms.toString() : script; -} - -export function addAnimationToScript( - script: string, - animation: Omit, -): { script: string; id: string } { - 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); - ms.appendLeft(insertionPoint, "\n" + statementCode); - - const result = ms.toString(); - const reParsed = parseGsapScriptAcornForWrite(result); - const newId = reParsed?.located[reParsed.located.length - 1]?.id ?? ""; - return { script: result, id: newId }; -} - -export function removeAnimationFromScript(script: string, animationId: string): string { - const parsed = parseGsapScriptAcornForWrite(script); - if (!parsed) return script; - const target = parsed.located.find((l) => l.id === animationId); - if (!target) return script; - - const ms = new MagicString(script); - const N = target.call.node; - const exprStmt = findEnclosingExpressionStatement(target.call.ancestors); - - if (N.callee?.object?.type !== "CallExpression" && exprStmt?.expression === N) { - // Standalone `tl.method(...)` — remove the whole ExpressionStatement - const end = - exprStmt.end < script.length && script[exprStmt.end] === "\n" - ? exprStmt.end + 1 - : exprStmt.end; - ms.remove(exprStmt.start, end); - } else { - // Chain link — splice out `.method(args)` from N.callee.object.end to N.end - ms.remove(N.callee.object.end, N.end); - } - - return ms.toString(); -} - -// ── Flat-tween → keyframes conversion ────────────────────────────────────────── -// -// Mirror recast's convertToKeyframesInScript: when the first keyframe op lands -// on a flat to()/from()/fromTo() tween, rewrite its vars object to -// `{ keyframes: { "0%": {from}, "100%": {to} }, , -// ease: "none"? }` and convert from()/fromTo() to to(). We rebuild the whole -// vars ObjectExpression in one ms.overwrite (single-edit-per-node), so the next -// keyframe-add re-parses cleanly. - -// Identity value for an editable transform/style prop (recast's CSS_IDENTITY). -const CSS_IDENTITY: Record = { - opacity: 1, - autoAlpha: 1, - scale: 1, - scaleX: 1, - scaleY: 1, -}; - -function cssIdentityValue(prop: string): number { - return CSS_IDENTITY[prop] ?? 0; -} - -// Keys NOT in the editable set — preserved verbatim on the converted vars object -// (matches the parser's classification: builtin/dropped/extras keys). -const NON_EDITABLE_VAR_KEYS = new Set([ - "duration", - "delay", - "onComplete", - "onStart", - "onUpdate", - "onRepeat", - "stagger", - "yoyo", - "repeat", - "repeatDelay", - "snap", - "overwrite", - "immediateRender", -]); - -/** The CSS-identity counterpart of a props record (numbers → identity value). */ -function identityProps( - properties: Record, -): Record { - const identity: Record = {}; - for (const [k, v] of Object.entries(properties)) { - if (v != null) identity[k] = typeof v === "number" ? cssIdentityValue(k) : v; - } - return identity; -} - -/** Resolve the 0%/100% endpoint records for a tween being converted. */ -function conversionEndpoints(animation: GsapAnimation): { - fromProps: Record; - toProps: Record; -} { - if (animation.method === "from") { - return { fromProps: { ...animation.properties }, toProps: identityProps(animation.properties) }; - } - if (animation.method === "fromTo") { - return { - fromProps: { ...(animation.fromProperties ?? {}) }, - toProps: { ...animation.properties }, - }; - } - // to(): 0% is the CSS identity state, 100% is the authored props. - return { fromProps: identityProps(animation.properties), toProps: { ...animation.properties } }; -} - -/** Collect preserved (non-editable) `key: value` entries from the original vars node. */ -function preservedVarsEntries(varsNode: Node, source: string): string[] { - const entries: string[] = []; - if (varsNode?.type !== "ObjectExpression") return entries; - for (const prop of varsNode.properties ?? []) { - if (!isObjectProperty(prop)) continue; - const key = propKeyName(prop); - if (typeof key !== "string" || !NON_EDITABLE_VAR_KEYS.has(key)) continue; - entries.push(`${safeKey(key)}: ${source.slice(prop.value.start, prop.value.end)}`); - } - return entries; -} - -/** Build the rebuilt vars-object code for a converted flat tween. */ -function buildConvertedVarsCode(animation: GsapAnimation, varsNode: Node, source: string): string { - const { fromProps, toProps } = conversionEndpoints(animation); - const easeEach = animation.ease; - const easeEachEntry = easeEach ? `, easeEach: ${JSON.stringify(easeEach)}` : ""; - const kfCode = `{ "0%": ${recordToCode(fromProps)}, "100%": ${recordToCode(toProps)}${easeEachEntry} }`; - const entries = [`keyframes: ${kfCode}`, ...preservedVarsEntries(varsNode, source)]; - if (easeEach) entries.push(`ease: "none"`); - return `{ ${entries.join(", ")} }`; -} - -/** Rename a from()/fromTo() call to to(), dropping fromTo's leading from-vars arg. */ -function convertMethodToTo( - ms: MagicString, - animation: GsapAnimation, - call: Node, - varsNode: Node, -): void { - if (animation.method !== "from" && animation.method !== "fromTo") return; - const calleeProp = call.node.callee?.property; - if (calleeProp) ms.overwrite(calleeProp.start, calleeProp.end, "to"); - // Remove the from-vars arg and its trailing separator up to the to-vars arg. - if (animation.method === "fromTo" && call.fromArg) ms.remove(call.fromArg.start, varsNode.start); -} - -function convertFlatTweenToKeyframes(script: string, target: Node): string { - const animation: GsapAnimation = target.animation; - if (animation.keyframes || animation.method === "set") return script; - const call = target.call; - const varsNode = call.varsArg; - if (varsNode?.type !== "ObjectExpression") return script; - - const ms = new MagicString(script); - ms.overwrite(varsNode.start, varsNode.end, buildConvertedVarsCode(animation, varsNode, script)); - convertMethodToTo(ms, animation, call, varsNode); - return ms.toString(); -} - -// ── Keyframe write ops ──────────────────────────────────────────────────────── -// -// Design: mirror the recast writer's rebuild-the-node model. The recast writer -// mutates AST nodes in place and re-prints, so it never has an offset-overlap -// problem. Here we instead compute the FINAL property record for every keyframe -// value node that must change (the target merge, `_auto` endpoint sync, and -// backfilled siblings) against the ORIGINAL parsed AST, then emit exactly ONE -// `ms.overwrite(valueNode.start, valueNode.end, code)` per changed node (and a -// single insert for a brand-new key). No node is ever both overwritten and -// appended into, so the splices can never overlap. - -const PERCENTAGE_KEY_RE = /^(\d+(?:\.\d+)?)%$/; - -// Matches recast's PCT_TOLERANCE: percentages within 2 of an existing key are -// treated as the same keyframe (merge), not a new insert. -const PCT_TOLERANCE = 2; - -function percentageFromKey(key: string): number { - const m = PERCENTAGE_KEY_RE.exec(key); - return m ? Number.parseFloat(m[1] ?? "0") : Number.NaN; -} - -/** Serialize a final keyframe property record (number|string values) to code. */ -function recordToCode(record: Record): string { - const entries = Object.entries(record).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`); - return `{ ${entries.join(", ")} }`; -} - -/** Percentage-keyed property nodes of a keyframes ObjectExpression, in source order. */ -function percentagePropsOf(kfNode: Node): Node[] { - return (kfNode.properties ?? []).filter((p: Node) => { - if (!isObjectProperty(p)) return false; - const key = propKeyName(p); - return typeof key === "string" && PERCENTAGE_KEY_RE.test(key); - }); -} - -const LITERAL_NODE_TYPES = new Set(["Literal", "NumericLiteral", "StringLiteral"]); - -/** Read one value node: a number/string literal, a negative number, or raw source. */ -// fallow-ignore-next-line complexity -function readValueNode(v: Node, source: string): number | string { - if ( - LITERAL_NODE_TYPES.has(v?.type) && - (typeof v.value === "number" || typeof v.value === "string") - ) { - return v.value; - } - if ( - v?.type === "UnaryExpression" && - v.operator === "-" && - typeof v.argument?.value === "number" - ) { - return -v.argument.value; - } - return `__raw:${source.slice(v.start, v.end)}`; -} - -/** - * Read a keyframe value ObjectExpression into a record, mirroring the parser's - * `objectExpressionToRecord`: literals resolve to their value; anything else is - * preserved as `__raw:` so serializeValue round-trips it verbatim. - * Keyframe values are literals in practice, so the raw fallback is rarely hit. - */ -function valueNodeToRecord(valueNode: Node, source: string): Record { - const record: Record = {}; - if (valueNode?.type !== "ObjectExpression") return record; - for (const prop of valueNode.properties ?? []) { - if (!isObjectProperty(prop)) continue; - const key = propKeyName(prop); - if (typeof key !== "string") continue; - record[key] = readValueNode(prop.value, source); - } - return record; -} - -/** True when a keyframe value record carries the synthetic `_auto` marker. */ -function recordHasAuto(record: Record): boolean { - return "_auto" in record; -} - -/** - * Compute `_auto` endpoint overwrites: when the new keyframe is the immediate - * neighbor of an `_auto` 0% or 100% endpoint, that endpoint is rewritten to - * `{ ...newProps, _auto: 1 }`. Only fires for interior keyframes. Returns the - * percentage→overwrite map so the caller can fold these into the per-node final - * records (never a separate splice). - */ -function autoEndpointOverwrites( - kfNode: Node, - source: string, - percentage: number, - properties: Record, -): Map> { - const result = new Map>(); - if (percentage <= 0 || percentage >= 100) return result; - const pctProps = percentagePropsOf(kfNode); - const allPcts = pctProps - .map((p: Node) => percentageFromKey(propKeyName(p) ?? "")) - .filter((n: number) => !Number.isNaN(n) && n !== percentage) - .sort((a: number, b: number) => a - b); - const leftNeighbor = allPcts.filter((p: number) => p < percentage).pop(); - const rightNeighbor = allPcts.find((p: number) => p > percentage); - for (const endPct of [0, 100]) { - const isNeighbor = endPct === 0 ? leftNeighbor === 0 : rightNeighbor === 100; - if (!isNeighbor) continue; - const endProp = pctProps.find((p: Node) => percentageFromKey(propKeyName(p) ?? "") === endPct); - if (!endProp) continue; - const rec = valueNodeToRecord(endProp.value, source); - if (!recordHasAuto(rec)) continue; - result.set(endProp, { ...properties, _auto: 1 }); - } - return result; -} - -function findKfPropByPct(kfNode: Node, percentage: number): { prop: Node; idx: number } | null { - // Match the CLOSEST keyframe within tolerance, not the first one within range. - // Keyframes at e.g. 0/49/50/100 are all valid (the SDK dedups to a unique - // match at TOLERANCE=0.001 upstream); picking the first-within-PCT_TOLERANCE=2 - // would hit 49% when the caller meant 50%. Tie-break on the earliest index so - // the choice stays deterministic. - const props = kfNode.properties ?? []; - let best: { prop: Node; idx: number } | null = null; - let bestDist = Number.POSITIVE_INFINITY; - for (let i = 0; i < props.length; i++) { - const prop = props[i]; - if (!isObjectProperty(prop)) continue; - const key = propKeyName(prop); - if (typeof key !== "string") continue; - const dist = Math.abs(percentageFromKey(key) - percentage); - if (dist <= PCT_TOLERANCE && dist < bestDist) { - best = { prop, idx: i }; - bestDist = dist; - } - } - return best; -} - -export function updateKeyframeInScript( - script: string, - animationId: string, - percentage: number, - properties: Record, - ease?: string, -): string { - const parsed = parseGsapScriptAcornForWrite(script); - if (!parsed) return script; - const target = parsed.located.find((l) => l.id === animationId); - if (!target) return script; - - const kfPropNode = findPropertyNode(target.call.varsArg, "keyframes"); - if (!kfPropNode) return script; - - // Array-form keyframes (`keyframes: [{x,y}, ...]`) carry no explicit percentages - // — GSAP distributes them evenly, and the runtime read assigns even percentages - // (0, 100/(n-1), …). Map the percentage back to an array index and overwrite that - // element in place (preserving the array form). Without this the function bailed - // on the ObjectExpression check, so dragging a motion-path node on an array-form - // tween committed nothing (server no-op). - if (kfPropNode.value?.type === "ArrayExpression") { - return updateArrayKeyframeByPct(script, kfPropNode.value, percentage, properties, ease); - } - if (kfPropNode.value?.type !== "ObjectExpression") return script; - - const match = findKfPropByPct(kfPropNode.value, percentage); - if (!match) return script; - - const record: Record = { ...properties }; - if (ease) record.ease = ease; - const ms = new MagicString(script); - ms.overwrite(match.prop.value.start, match.prop.value.end, recordToCode(record)); - return ms.toString(); -} - -// ponytail: even-spacing index map; if array keyframes ever carry per-element -// `duration`, switch to matching the closest cumulative position. -function updateArrayKeyframeByPct( - script: string, - arrayNode: Node, - percentage: number, - properties: Record, - ease?: string, -): string { - const elements = ((arrayNode.elements ?? []) as Array).filter( - (el): el is Node => !!el && el.type === "ObjectExpression", - ); - const n = elements.length; - if (n === 0) return script; - const idx = n > 1 ? Math.round((percentage / 100) * (n - 1)) : 0; - const el = elements[Math.max(0, Math.min(n - 1, idx))]; - if (!el) return script; - const merged: Record = { - ...valueNodeToRecord(el, script), - ...properties, - }; - if (ease) merged.ease = ease; - const ms = new MagicString(script); - ms.overwrite(el.start, el.end, recordToCode(merged)); - return ms.toString(); -} - -/** - * Build the final property record for the keyframe at `percentage`. If a - * keyframe already exists there, MERGE the new props over the existing record - * (preserve untouched props, preserve `_auto`, preserve the existing per-keyframe - * ease when the op omits one); otherwise it's just the new props. - */ -function buildTargetRecord( - existing: { prop: Node; idx: number } | null, - source: string, - properties: Record, - ease: string | undefined, -): Record { - if (!existing || existing.prop.value?.type !== "ObjectExpression") { - const record: Record = { ...properties }; - if (ease) record.ease = ease; - return record; - } - const existingRecord = valueNodeToRecord(existing.prop.value, source); - const existingEase = typeof existingRecord.ease === "string" ? existingRecord.ease : undefined; - const merged: Record = { ...existingRecord }; - for (const [k, v] of Object.entries(properties)) merged[k] = v; - const finalEase = ease ?? existingEase; - if (finalEase) merged.ease = finalEase; - else delete merged.ease; - return merged; -} - -/** - * Compute the backfilled final record for one sibling keyframe: append any of - * `newPropKeys` it's missing, using the backfill default. Returns null when - * nothing changes (so the caller emits no overwrite for it). - */ -function backfilledSiblingRecord( - valueNode: Node, - source: string, - newPropKeys: string[], - backfillDefaults: Record, -): Record | null { - if (valueNode?.type !== "ObjectExpression") return null; - const record = valueNodeToRecord(valueNode, source); - let changed = false; - for (const pk of newPropKeys) { - const defaultVal = backfillDefaults[pk]; - if (pk in record || defaultVal == null) continue; - record[pk] = defaultVal; - changed = true; - } - return changed ? record : null; -} - -/** A located tween whose varsArg has a static keyframes ObjectExpression, or null. */ -function locateWithKeyframes( - script: string, - animationId: string, -): { script: string; parsed: ParsedGsapAcornForWrite; target: Node; kfNode: Node } | null { - const parsed = parseGsapScriptAcornForWrite(script); - if (!parsed) return null; - // Converting from()/fromTo() to to() rewrites the content-derived id; match - // recast's locateAnimationWithFallback by remapping the method segment. - const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-"); - const target = - parsed.located.find((l) => l.id === animationId) ?? - parsed.located.find((l) => l.id === convertedId); - if (!target) return null; - const kfPropNode = findPropertyNode(target.call.varsArg, "keyframes"); - if (!kfPropNode || kfPropNode.value?.type !== "ObjectExpression") return null; - return { script, parsed, target, kfNode: kfPropNode.value }; -} - -/** Locate a tween's keyframes object, converting a flat tween first if absent. */ -// Array-form keyframes (`keyframes: [{x,y}, …]`) → even-percentage object form -// (`{ "0%": {…}, "33.3%": {…}, … }`). Inserting a keyframe needs percentage keys, -// which an even array can't host. Runtime-identical; mirrors the recast path. -function convertArrayKeyframesToObject(script: string, target: Node): string { - const kfPropNode = findPropertyNode(target.call.varsArg, "keyframes"); - if (!kfPropNode || kfPropNode.value?.type !== "ArrayExpression") return script; - const els = ((kfPropNode.value.elements ?? []) as Array).filter( - (el): el is Node => !!el && el.type === "ObjectExpression", - ); - const n = els.length; - if (n === 0) return script; - const entries = els.map((el, i) => { - const pct = n > 1 ? Math.round((i / (n - 1)) * 1000) / 10 : 0; - return `${JSON.stringify(`${pct}%`)}: ${script.slice(el.start, el.end)}`; - }); - const ms = new MagicString(script); - ms.overwrite(kfPropNode.value.start, kfPropNode.value.end, `{ ${entries.join(", ")} }`); - return ms.toString(); -} - -function ensureKeyframesNode( - script: string, - animationId: string, -): { script: string; parsed: ParsedGsapAcornForWrite; target: Node; kfNode: Node } | null { - const direct = locateWithKeyframes(script, animationId); - if (direct) return direct; - - const parsed = parseGsapScriptAcornForWrite(script); - const target = parsed?.located.find((l) => l.id === animationId); - if (!target) return null; - - // Array-form keyframes → normalize to object form, then re-locate. - const kfProp = findPropertyNode(target.call.varsArg, "keyframes"); - if (kfProp?.value?.type === "ArrayExpression") { - const normalized = convertArrayKeyframesToObject(script, target); - if (normalized !== script) return locateWithKeyframes(normalized, animationId); - return null; - } - - // No static keyframes object — convert the flat tween, then re-locate. - const converted = convertFlatTweenToKeyframes(script, target); - if (converted === script) return null; - return locateWithKeyframes(converted, animationId); -} - -/** - * Compute the sibling keyframe nodes that need a backfilled prop, excluding the - * target keyframe and any node already being overwritten as an `_auto` endpoint. - */ -function collectBackfillOverwrites( - kfNode: Node, - src: string, - properties: Record, - backfillDefaults: Record | undefined, - skip: { existingProp: Node; endpoints: Map }, -): Map> { - const result = new Map>(); - if (!backfillDefaults) return result; - const newPropKeys = Object.keys(properties); - for (const prop of percentagePropsOf(kfNode)) { - if (prop === skip.existingProp || skip.endpoints.has(prop)) continue; - const rec = backfilledSiblingRecord(prop.value, src, newPropKeys, backfillDefaults); - if (rec) result.set(prop, rec); - } - return result; -} - -export function addKeyframeToScript( - script: string, - animationId: string, - percentage: number, - properties: Record, - ease?: string, - backfillDefaults?: Record, -): string { - const located = ensureKeyframesNode(script, animationId); - if (!located) return script; - const { script: src, kfNode } = located; - - const existing = findKfPropByPct(kfNode, percentage); - - // Final record for the target keyframe (merge if it already exists). - const targetRecord = buildTargetRecord(existing, src, properties, ease); - // `_auto` endpoint syncs fire only on new inserts; a merge landing ON an - // endpoint already preserves `_auto` via buildTargetRecord. - const endpointOverwrites = existing - ? new Map>() - : autoEndpointOverwrites(kfNode, src, percentage, properties); - // Backfilled siblings (each node changes at most once). - const backfillOverwrites = collectBackfillOverwrites(kfNode, src, properties, backfillDefaults, { - existingProp: existing?.prop, - endpoints: endpointOverwrites, - }); - - // Emit exactly one overwrite per changed node, plus one insert for a new key. - const ms = new MagicString(src); - if (existing) { - // Merge into the existing keyframe at this percentage, preserving sibling - // properties — overwrite only the given keys. (A whole-value overwrite here - // would silently drop other properties already keyframed at this percent.) - if (existing.prop.value?.type === "ObjectExpression") { - for (const [k, v] of Object.entries(properties)) { - upsertProp(ms, existing.prop.value, k, v); - } - if (ease !== undefined) upsertProp(ms, existing.prop.value, "ease", ease); - } else { - ms.overwrite(existing.prop.value.start, existing.prop.value.end, recordToCode(targetRecord)); - } - } else { - insertNewKeyframe(ms, kfNode, percentage, `${percentage}%`, recordToCode(targetRecord)); - } - for (const [prop, rec] of [...endpointOverwrites, ...backfillOverwrites]) { - ms.overwrite(prop.value.start, prop.value.end, recordToCode(rec)); - } - - return ms.toString(); -} - -/** Insert a brand-new `"pct%": {...}` property in sorted order. */ -function insertNewKeyframe( - ms: MagicString, - kfNode: Node, - percentage: number, - pctKey: string, - valueCode: string, -): void { - const allProps = (kfNode.properties ?? []).filter((p: Node) => isObjectProperty(p)); - let insertBeforeProp: Node = null; - for (const prop of allProps) { - const key = propKeyName(prop); - if (typeof key === "string" && percentageFromKey(key) > percentage) { - insertBeforeProp = prop; - break; - } - } - if (insertBeforeProp) { - ms.appendLeft(insertBeforeProp.start, `${JSON.stringify(pctKey)}: ${valueCode}, `); - } else { - const sep = allProps.length > 0 ? ", " : ""; - ms.appendLeft(kfNode.end - 1, `${sep}${JSON.stringify(pctKey)}: ${valueCode}`); - } -} - -/** - * Rebuild a vars ObjectExpression that has just dropped below two keyframes, - * collapsing `keyframes: {…}` back to a flat tween. Mirrors recast's - * collapseKeyframesToFlat: drop the `keyframes` + `easeEach` keys, preserve every - * other vars key verbatim, and splice the remaining keyframe's properties (minus - * its per-keyframe `ease`) in as flat vars keys. Single ms.overwrite of the whole - * vars node so the splice can't overlap the keyframe removal. - */ -function collapseKeyframesToFlat( - ms: MagicString, - varsNode: Node, - source: string, - remainingRecord: Record, -): void { - if (varsNode?.type !== "ObjectExpression") return; - const dropKeyframeKeys = (key: string) => key === "keyframes" || key === "easeEach"; - const { entries } = preservedEntries(varsNode, source, dropKeyframeKeys, {}); - for (const [k, v] of Object.entries(remainingRecord)) { - if (k !== "ease") entries.push(`${safeKey(k)}: ${valueToCode(v)}`); - } - ms.overwrite(varsNode.start, varsNode.end, `{ ${entries.join(", ")} }`); -} - -/** Implicit tween-relative percentage of array-form keyframe index `i` of `n` - * (GSAP distributes array keyframes evenly: 0%, 1/(n-1), …, 100%). */ -function arrayKeyframePct(i: number, n: number): number { - return n > 1 ? (i / (n - 1)) * 100 : 0; -} - -// Array-form keyframes (`keyframes: [{x,y}, …]`) carry no explicit percentages — -// GSAP distributes them evenly. removeKeyframeFromScript only handled the -// object-form (`keyframes: { "50%": {…} }`), so removing from an array-form tween -// was a silent no-op (and the downstream hold-sync then stranded an `hf-hold`). -// Resolve the element by its implicit percentage and splice it out; collapse to a -// flat tween when fewer than two remain (parity with the object-form path). -function removeArrayKeyframe( - ms: MagicString, - varsArg: Node, - arrNode: Node, - script: string, - percentage: number, -): boolean { - const elements: Node[] = (arrNode.elements ?? []).filter( - (e: Node | null): e is Node => !!e && e.type === "ObjectExpression", - ); - const n = elements.length; - if (n === 0) return false; - - let matchIdx = -1; - let bestDist = Number.POSITIVE_INFINITY; - for (let i = 0; i < n; i++) { - const dist = Math.abs(arrayKeyframePct(i, n) - percentage); - if (dist <= PCT_TOLERANCE && dist < bestDist) { - matchIdx = i; - bestDist = dist; - } - } - if (matchIdx === -1) return false; - - const remaining = elements.filter((_, i) => i !== matchIdx); - if (remaining.length < 2) { - const sole = remaining[0]; - const record = sole ? valueNodeToRecord(sole, script) : {}; - collapseKeyframesToFlat(ms, varsArg, script, record); - return true; - } - removeProp(ms, elements[matchIdx], elements); - return true; -} - -export function removeKeyframeFromScript( - script: string, - animationId: string, - percentage: number, -): string { - const parsed = parseGsapScriptAcornForWrite(script); - if (!parsed) return script; - const target = parsed.located.find((l) => l.id === animationId); - if (!target) return script; - - const kfPropNode = findPropertyNode(target.call.varsArg, "keyframes"); - if (!kfPropNode) return script; - - if (kfPropNode.value?.type === "ArrayExpression") { - const ms = new MagicString(script); - return removeArrayKeyframe(ms, target.call.varsArg, kfPropNode.value, script, percentage) - ? ms.toString() - : script; - } - - if (kfPropNode.value?.type !== "ObjectExpression") return script; - const kfNode = kfPropNode.value; - - const match = findKfPropByPct(kfNode, percentage); - if (!match) return script; - - const ms = new MagicString(script); - - // If removing this keyframe leaves fewer than two, collapse the keyframes - // object back to a flat tween (recast parity) instead of leaving a lone - // keyframe. We rebuild the whole vars node, so we never also splice the kf - // node — the two edits would overlap. - const remaining = percentagePropsOf(kfNode).filter((p) => p !== match.prop); - if (remaining.length < 2) { - const sole = remaining[0]; - const record = sole ? valueNodeToRecord(sole.value, script) : {}; - collapseKeyframesToFlat(ms, target.call.varsArg, script, record); - return ms.toString(); - } - - const allProps = (kfNode.properties ?? []).filter((p: Node) => isObjectProperty(p)); - removeProp(ms, match.prop, allProps); - return ms.toString(); -} - -export function removePropertyFromAnimation( - script: string, - animationId: string, - property: string, - from = false, -): string { - const parsed = parseGsapScriptAcornForWrite(script); - if (!parsed) return script; - const target = parsed.located.find((l) => l.id === animationId); - if (!target) return script; - const { call } = target; - const objNode = from ? (call.method === "fromTo" ? call.fromArg : null) : call.varsArg; - if (!objNode) return script; - const propNode = findPropertyNode(objNode, property); - if (!propNode) return script; - const allProps = (objNode.properties ?? []).filter((p: Node) => isObjectProperty(p)); - const ms = new MagicString(script); - removeProp(ms, propNode, allProps); - return ms.toString(); -} - -/** - * Remove all keyframes from a tween, collapsing to a flat tween with one - * keyframe's properties: the first for `from()`, the last otherwise (the - * destination = the visible resting state). - */ -export function removeAllKeyframesFromScript(script: string, animationId: string): string { - const parsed = parseGsapScriptAcornForWrite(script); - if (!parsed) return script; - const target = parsed.located.find((l) => l.id === animationId); - if (!target) return script; - const kfs = target.animation.keyframes?.keyframes; - if (!kfs || kfs.length === 0) return script; - - const sorted = [...kfs].sort((a, b) => a.percentage - b.percentage); - const collapse = target.call.method === "from" ? sorted[0] : sorted[sorted.length - 1]; - if (!collapse) return script; - - const ms = new MagicString(script); - overwriteVarsArg( - ms, - target.call, - buildVarsObjectCode(buildCollapsedFlatVars(target.animation, collapse)), - ); - return ms.toString(); -} - -// Flat vars for a tween collapsing its keyframes onto one stop: existing -// top-level props, then the collapse keyframe's props (skip per-keyframe -// `ease`), then duration/ease/extras. Drops keyframes + easeEach by omission. -function buildCollapsedFlatVars( - animation: GsapAnimation, - collapse: { properties: Record }, -): Record { - const flat: Record = { ...animation.properties }; - for (const [k, v] of Object.entries(collapse.properties)) { - if (k !== "ease") flat[k] = v; - } - if (animation.duration !== undefined) flat.duration = animation.duration; - if (animation.ease) flat.ease = animation.ease; - for (const [k, v] of Object.entries(animation.extras ?? {})) { - if (typeof v === "number" || typeof v === "string") flat[k] = v; - } - return flat; -} - -/** Build the full replacement vars object for a tween being converted to keyframes. */ -function buildKeyframesVarsCode( - animation: GsapAnimation, - fromProps: Record, - toProps: Record, - varsNode: Node, - source: string, - setDuration?: number, -): string { - const fromEntries = Object.entries(fromProps).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`); - const toEntries = Object.entries(toProps).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`); - const easeEntry = animation.ease ? `, easeEach: ${JSON.stringify(animation.ease)}` : ""; - const kfCode = `{ "0%": { ${fromEntries.join(", ")} }, "100%": { ${toEntries.join(", ")} }${easeEntry} }`; - // Preserve every non-editable key (duration/delay/callbacks/stagger/yoyo/…) - // verbatim from source — rebuilding from the animation object alone dropped - // `delay` (not a GsapAnimation field), shifting the tween's start time. - let preserved = preservedVarsEntries(varsNode, source); - // Converting a static `set` → drop its hold markers and give it a real duration - // so the keyframes span time. - if (setDuration !== undefined) { - preserved = preserved.filter((e) => !/^\s*(immediateRender|data|duration)\s*:/.test(e)); - } - const parts: string[] = [`keyframes: ${kfCode}`, ...preserved]; - if (setDuration !== undefined) parts.push(`duration: ${Math.max(0.001, setDuration)}`); - if (animation.ease) parts.push(`ease: "none"`); - return `{ ${parts.join(", ")} }`; -} - -/** - * Convert a flat tween (to/from/fromTo) to percentage-keyframes format. - * `resolvedFromValues` supplies the current DOM state: overrides the 0% endpoint - * for `to()`, the 100% endpoint for `from()`, or merges into toProps for `fromTo()`. - */ -export function convertToKeyframesFromScript( - script: string, - animationId: string, - resolvedFromValues?: Record, - setDuration = 1, -): string { - const parsed = parseGsapScriptAcornForWrite(script); - if (!parsed) return script; - const target = parsed.located.find((l) => l.id === animationId); - if (!target) return script; - const { animation, call } = target; - if (animation.keyframes) return script; - const isSet = call.method === "set"; - - const { fromProps, toProps } = resolveConversionProps(animation, resolvedFromValues); - const ms = new MagicString(script); - - // A GLOBAL `gsap.set(...)` is off-timeline; rewriting only the method emits - // `gsap.to(...)`, which fires once at load and isn't on the paused master - // timeline (the engine can't seek/render it). Re-root onto the timeline var - // and add the position arg the set lacks so the converted tween is seekable. - if (isSet && animation.global) { - const calleeObj = call.node.callee.object; - if (calleeObj?.type === "Identifier") { - ms.overwrite(calleeObj.start, calleeObj.end, parsed.timelineVar); - } - const args = call.node.arguments; - if (args.length > 0 && args.length < 3) { - ms.appendLeft(args[args.length - 1].end, ", 0"); - } - } - - // set/from/fromTo all become `to`; fromTo also drops its `from` argument. - if (call.method === "from" || call.method === "fromTo" || isSet) { - ms.overwrite(call.node.callee.property.start, call.node.callee.property.end, "to"); - } - if (call.method === "fromTo" && call.fromArg) { - ms.remove(call.fromArg.start, call.varsArg.start); - } - overwriteVarsArg( - ms, - call, - buildKeyframesVarsCode( - animation, - fromProps, - toProps, - call.varsArg, - script, - isSet ? setDuration : undefined, - ), - ); - - return ms.toString(); -} - -// ── Keyframe-object code builder ───────────────────────────────────────────── - -/** Build a percentage-keyframes object literal: `{ "0%": { x: 0 }, "100%": { x: 100 } }`. */ -function buildKeyframeObjectCode( - keyframes: Array<{ - percentage: number; - properties: Record; - ease?: string; - auto?: boolean; - }>, - easeEach?: string, -): string { - const entries = keyframes.map((kf) => { - const props = Object.entries(kf.properties).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`); - if (kf.ease) props.push(`ease: ${JSON.stringify(kf.ease)}`); - if (kf.auto) props.push(`_auto: 1`); - return `${JSON.stringify(`${kf.percentage}%`)}: { ${props.join(", ")} }`; - }); - if (easeEach) entries.push(`easeEach: ${JSON.stringify(easeEach)}`); - return `{ ${entries.join(", ")} }`; -} - -// ── Materialize keyframes ──────────────────────────────────────────────────── - -/** - * Replace a dynamic or static keyframes expression with a fully-resolved - * percentage-keyframes object. Called when a user first edits a dynamically- - * generated keyframe in the studio so it becomes statically editable. - */ -export function materializeKeyframesFromScript( - script: string, - animationId: string, - keyframes: Array<{ - percentage: number; - properties: Record; - ease?: string; - }>, - easeEach?: string, - resolvedSelector?: string, -): string { - // An empty keyframe list has no materialized form — rebuilding vars with an - // empty keyframes object would empty the animation. No-op instead. - if (keyframes.length === 0) return script; - const parsed = parseGsapScriptAcornForWrite(script); - if (!parsed) return script; - const target = parsed.located.find((l) => l.id === animationId); - if (!target) return script; - - const { call } = target; - const sorted = [...keyframes].sort((a, b) => a.percentage - b.percentage); - const kfObjCode = buildKeyframeObjectCode(sorted, easeEach); - const ms = new MagicString(script); - - if (resolvedSelector) { - const selectorArg = call.node.arguments[0]; - if (selectorArg) - ms.overwrite(selectorArg.start, selectorArg.end, JSON.stringify(resolvedSelector)); - } - - const kfProp = findPropertyNode(call.varsArg, "keyframes"); - if (kfProp) { - ms.overwrite(kfProp.value.start, kfProp.value.end, kfObjCode); - } else if (call.varsArg?.type === "ObjectExpression") { - const vars = call.varsArg; - if (vars.properties.length > 0) { - ms.prependLeft(vars.properties[0].start, `keyframes: ${kfObjCode}, `); - } else { - ms.appendLeft(vars.end - 1, `keyframes: ${kfObjCode}`); - } - } - - const eachProp = findPropertyNode(call.varsArg, "easeEach"); - if (eachProp) { - const allProps = (call.varsArg.properties ?? []).filter((p: Node) => isObjectProperty(p)); - removeProp(ms, eachProp, allProps); - } - - return ms.toString(); -} - -// ── Add animation with keyframes ────────────────────────────────────────────── - -/** Insert a new keyframed `to()` call and return the new animation ID. */ -export function addAnimationWithKeyframesToScript( - script: string, - targetSelector: string, - position: number, - duration: number, - keyframes: Array<{ - percentage: number; - properties: Record; - ease?: string; - auto?: boolean; - }>, - ease?: string, - easeEach?: string, -): { script: string; id: string } { - const parsed = parseGsapScriptAcornForWrite(script); - if (!parsed) return { script, id: "" }; - const insertionPoint = findInsertionPoint(parsed); - if (insertionPoint === null) return { script, id: "" }; - - const sorted = [...keyframes].sort((a, b) => a.percentage - b.percentage); - const kfObjCode = buildKeyframeObjectCode(sorted, easeEach); - const varParts = [`keyframes: ${kfObjCode}`, `duration: ${valueToCode(duration)}`]; - if (ease) varParts.push(`ease: ${JSON.stringify(ease)}`); - const stmtCode = `${parsed.timelineVar}.to(${JSON.stringify(targetSelector)}, { ${varParts.join(", ")} }, ${valueToCode(position)});`; - - const ms = new MagicString(script); - ms.appendLeft(insertionPoint, "\n" + stmtCode); - - const result = ms.toString(); - const reParsed = parseGsapScriptAcornForWrite(result); - const newId = reParsed?.located[reParsed.located.length - 1]?.id ?? ""; - return { script: result, id: newId }; -} - -// ── Split into property groups ──────────────────────────────────────────────── - -function collectPropertyKeys(anim: GsapAnimation): Set { - const keys = new Set(); - if (anim.keyframes) { - for (const kf of anim.keyframes.keyframes) { - for (const k of Object.keys(kf.properties)) keys.add(k); - } - } else { - for (const k of Object.keys(anim.properties)) keys.add(k); - } - return keys; -} - -function partitionPropertyGroups(keys: Set): Map { - const groups = new Map(); - for (const key of keys) { - if (key === "transformOrigin") continue; - const group = classifyPropertyGroup(key); - let arr = groups.get(group); - if (!arr) { - arr = []; - groups.set(group, arr); - } - arr.push(key); - } - return groups; -} - -function assignTransformOrigin(groupProps: Map): void { - let largestGroup: PropertyGroupName | undefined; - let largestCount = 0; - for (const [group, props] of groupProps) { - if (props.length > largestCount) { - largestCount = props.length; - largestGroup = group; - } - } - const largest = largestGroup ? groupProps.get(largestGroup) : undefined; - if (largest) largest.push("transformOrigin"); -} - -function filterGroupKeyframes( - kfs: GsapPercentageKeyframe[], - propSet: Set, -): Array<{ percentage: number; properties: Record; ease?: string }> { - const result: Array<{ - percentage: number; - properties: Record; - ease?: string; - }> = []; - for (const kf of kfs) { - const filtered: Record = {}; - for (const [k, v] of Object.entries(kf.properties)) { - if (propSet.has(k)) filtered[k] = v; - } - if (Object.keys(filtered).length > 0) { - result.push({ - percentage: kf.percentage, - properties: filtered, - ...(kf.ease ? { ease: kf.ease } : {}), - }); - } - } - return result; -} - -function filterGroupProperties( - properties: Record, - propSet: Set, -): Record { - const result: Record = {}; - for (const [k, v] of Object.entries(properties)) { - if (propSet.has(k)) result[k] = v; - } - return result; -} - -function addGroupAnimToScript( - script: string, - anim: GsapAnimation, - propSet: Set, -): { script: string; id: string } { - if (anim.keyframes) { - const groupKeyframes = filterGroupKeyframes(anim.keyframes.keyframes, propSet); - if (groupKeyframes.length === 0) return { script, id: "" }; - const pos = typeof anim.position === "number" ? anim.position : 0; - return addAnimationWithKeyframesToScript( - script, - anim.targetSelector, - pos, - anim.duration ?? 0.5, - groupKeyframes, - anim.keyframes.easeEach ?? anim.ease, - ); - } - const groupProperties = filterGroupProperties(anim.properties, propSet); - if (Object.keys(groupProperties).length === 0) return { script, id: "" }; - const fromProperties = - anim.method === "fromTo" && anim.fromProperties - ? filterGroupProperties(anim.fromProperties, propSet) - : undefined; - return addAnimationToScript(script, { - targetSelector: anim.targetSelector, - method: anim.method, - position: anim.position, - duration: anim.duration, - ease: anim.ease, - properties: groupProperties, - fromProperties, - extras: anim.extras, - }); -} - -/** - * Split a mixed-property tween into one tween per property group (position, - * scale, visual, etc.) so each group can be edited independently. - * Returns the updated script and the IDs of the newly-created tweens. - */ -export function splitIntoPropertyGroupsFromScript( - script: string, - animationId: string, -): { script: string; ids: string[] } { - const parsed = parseGsapScriptAcornForWrite(script); - if (!parsed) return { script, ids: [animationId] }; - const target = parsed.located.find((l) => l.id === animationId); - if (!target) return { script, ids: [animationId] }; - const { animation } = target; - - const allPropKeys = collectPropertyKeys(animation); - const groupProps = partitionPropertyGroups(allPropKeys); - if (groupProps.size <= 1) return { script, ids: [animationId] }; - if (allPropKeys.has("transformOrigin")) assignTransformOrigin(groupProps); - - let result = removeAnimationFromScript(script, animationId); - for (const [, props] of groupProps) { - const { script: next, id } = addGroupAnimToScript(result, animation, new Set(props)); - if (id) result = next; - } - - const reParsed = parseGsapScriptAcornForWrite(result); - const newIds = (reParsed?.located ?? []) - .filter((l) => l.animation.targetSelector === animation.targetSelector) - .map((l) => l.id); - return { script: result, ids: newIds }; -} - -// ── Label write ops ─────────────────────────────────────────────────────────── - -/** True when `expr` is `tl.(…)` rooted at the timeline var. */ -function isTimelineMethodCall(expr: Node, timelineVar: string, method: string): boolean { - return ( - expr?.type === "CallExpression" && - expr.callee?.type === "MemberExpression" && - isTimelineRooted(expr.callee.object, timelineVar) && - expr.callee.property?.name === method - ); -} - -/** True when `expr` is `tl.addLabel("", …)` rooted at the timeline var. */ -function isAddLabelCall(expr: Node, timelineVar: string, name: string): boolean { - const firstArg = expr?.arguments?.[0]; - return ( - isTimelineMethodCall(expr, timelineVar, "addLabel") && - firstArg?.type === "Literal" && - firstArg.value === name - ); -} - -/** Every `tl.addLabel("", …)` ExpressionStatement in the script. */ -function findLabelStatements(parsed: ParsedGsapAcornForWrite, name: string): Node[] { - const targets: Node[] = []; - acornWalk.simple(parsed.ast, { - ExpressionStatement(node: Node) { - if (isAddLabelCall(node.expression, parsed.timelineVar, name)) targets.push(node); - }, - }); - return targets; -} - -export function addLabelToScript(script: string, name: string, position: number): string { - const parsed = parseGsapScriptAcornForWrite(script); - if (!parsed) return script; - - // If the label already exists, MOVE it (overwrite its position) rather than - // appending a duplicate. Two same-named addLabel statements make removeLabel - // over-remove — it deletes every match, including a pre-existing label the - // user never touched. - const existing = findLabelStatements(parsed, name)[0]; - if (existing) { - const ms = new MagicString(script); - const posArg = existing.expression.arguments?.[1]; - if (posArg) ms.overwrite(posArg.start, posArg.end, valueToCode(position)); - else ms.appendLeft(existing.expression.end - 1, `, ${valueToCode(position)}`); - return ms.toString(); - } - - 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 = findLabelStatements(parsed, name); - 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(); -} - -// ── Arc path helpers ───────────────────────────────────────────────────────── - -/** - * Remove a set of properties from an ObjectExpression in a single pass. - * Groups consecutive marked props into blocks to avoid overlapping remove ranges. - */ -function removePropsByKey(ms: MagicString, objNode: Node, keys: Set): void { - if (objNode?.type !== "ObjectExpression") return; - const allProps = (objNode.properties ?? []).filter(isObjectProperty); - const marked = allProps.map((p: Node) => keys.has(propKeyName(p) ?? "")); - let i = 0; - while (i < allProps.length) { - if (!marked[i]) { - i++; - continue; - } - const blockStart = i; - while (i < allProps.length && marked[i]) i++; - ms.remove(...blockRemoveRange(allProps, blockStart, i)); - } -} - -function blockRemoveRange( - allProps: Node[], - blockStart: number, - blockEnd: number, -): [number, number] { - if (blockStart === 0 && blockEnd === allProps.length) - return [allProps[0].start, allProps[allProps.length - 1].end]; - if (blockStart === 0) return [allProps[0].start, allProps[blockEnd].start]; - return [allProps[blockStart - 1].end, allProps[blockEnd - 1].end]; -} - -// fallow-ignore-next-line complexity -function readLastWaypointXY(mpVal: Node): { x: number | null; y: number | null } { - if (mpVal?.type !== "ObjectExpression") return { x: null, y: null }; - const pathProp = findPropertyNode(mpVal, "path"); - if (pathProp?.value?.type !== "ArrayExpression") return { x: null, y: null }; - const elems: Node[] = pathProp.value.elements ?? []; - const last = elems[elems.length - 1]; - if (last?.type !== "ObjectExpression") return { x: null, y: null }; - return { - x: readNumericLiteralNode(findPropertyNode(last, "x")?.value), - y: readNumericLiteralNode(findPropertyNode(last, "y")?.value), - }; -} - -/** - * Read a numeric value node — a plain numeric literal or a unary-minus negative - * literal (e.g. `-120`). Returns null for anything non-numeric. Without the - * UnaryExpression branch, negative waypoint coords (parsed as a UnaryExpression - * with no `.value`) would be lost when disabling an arc path. - */ -function readNumericLiteralNode(v: Node): number | null { - if (LITERAL_NODE_TYPES.has(v?.type) && typeof v.value === "number") return v.value; - if ( - v?.type === "UnaryExpression" && - v.operator === "-" && - typeof v.argument?.value === "number" - ) { - return -v.argument.value; - } - return null; -} - -function disableArcPath(ms: MagicString, call: TweenCallInfo): boolean { - const mpProp = findPropertyNode(call.varsArg, "motionPath"); - if (!mpProp) return false; - const { x, y } = readLastWaypointXY(mpProp.value); - if (x === null && y === null) { - const allProps = (call.varsArg.properties ?? []).filter(isObjectProperty); - removeProp(ms, mpProp, allProps); - return true; - } - // Overwrite the entire motionPath property with the recovered x/y pair — avoids - // the appendLeft+remove range-boundary issue in MagicString. - const parts: string[] = []; - if (x !== null) parts.push(`x: ${x}`); - if (y !== null) parts.push(`y: ${y}`); - ms.overwrite(mpProp.start, mpProp.end, parts.join(", ")); - return true; -} - -function stripXYFromKeyframes(ms: MagicString, kfPropNode: Node): void { - if (kfPropNode?.value?.type !== "ObjectExpression") return; - const xyKeys = new Set(["x", "y"]); - for (const pctProp of (kfPropNode.value.properties ?? []).filter(isObjectProperty)) { - const k = propKeyName(pctProp); - if (typeof k === "string" && k.endsWith("%") && pctProp.value?.type === "ObjectExpression") { - removePropsByKey(ms, pctProp.value, xyKeys); - } - } -} - -function enableArcPath( - ms: MagicString, - call: TweenCallInfo, - animation: GsapAnimation, - config: ArcPathConfig, -): boolean { - const waypoints = extractArcWaypoints(animation); - if (waypoints.length < 2) return false; - const segments: ArcPathSegment[] = - config.segments.length === waypoints.length - 1 - ? config.segments - : Array.from({ length: waypoints.length - 1 }, () => ({ curviness: 1 })); - const motionPathCode = buildMotionPathObjectCode({ - waypoints, - segments, - autoRotate: config.autoRotate, - }); - const vars = call.varsArg; - if (vars?.type !== "ObjectExpression") return false; - // Insert motionPath right after the opening `{` (appendRight at start+1) so the - // insertion point can never coincide with the end boundary of the x/y removal - // range. upsertProp would appendLeft at `end - 1`, which collides with a - // remove-range that ends at the same offset when x/y are the only props — - // MagicString then discards the append and the output loses everything. - const editable = (vars.properties ?? []).filter(isObjectProperty); - const survivesRemoval = editable.some((p: Node) => { - const k = propKeyName(p); - return k !== "x" && k !== "y"; - }); - const sep = survivesRemoval ? ", " : ""; - ms.appendRight(vars.start + 1, ` motionPath: ${motionPathCode}${sep}`); - stripXYFromKeyframes(ms, findPropertyNode(call.varsArg, "keyframes")); - removePropsByKey(ms, call.varsArg, new Set(["x", "y"])); - return true; -} - -export function setArcPathInScript( - script: string, - animationId: string, - config: ArcPathConfig, -): string { - const parsed = parseGsapScriptAcornForWrite(script); - if (!parsed) return script; - const target = parsed.located.find((l) => l.id === animationId); - if (!target) return script; - const ms = new MagicString(script); - const handled = config.enabled - ? enableArcPath(ms, target.call, target.animation, config) - : disableArcPath(ms, target.call); - return handled ? ms.toString() : script; -} - -export function updateArcSegmentInScript( - script: string, - animationId: string, - segmentIndex: number, - update: Partial, -): string { - const parsed = parseGsapScriptAcornForWrite(script); - if (!parsed) return script; - const target = parsed.located.find((l) => l.id === animationId); - if (!target) return script; - - const { call, animation } = target; - if (!animation.arcPath?.enabled) return script; - - const segments = [...animation.arcPath.segments]; - const existingSeg = segments[segmentIndex]; - if (segmentIndex < 0 || segmentIndex >= segments.length || !existingSeg) return script; - - segments[segmentIndex] = { ...existingSeg, ...update }; - - const waypoints = extractArcWaypoints(animation); - if (waypoints.length < 2) return script; - - const motionPathCode = buildMotionPathObjectCode({ - waypoints, - segments, - autoRotate: animation.arcPath.autoRotate, - }); - - const mpProp = findPropertyNode(call.varsArg, "motionPath"); - if (!mpProp) return script; - - const ms = new MagicString(script); - ms.overwrite(mpProp.value.start, mpProp.value.end, motionPathCode); - return ms.toString(); -} - -export function removeArcPathFromScript(script: string, animationId: string): string { - return setArcPathInScript(script, animationId, { - enabled: false, - autoRotate: false, - segments: [], - }); -} - -// ── splitAnimationsInScript helpers ────────────────────────────────────────── - -/** Overwrite the selector (first arg) of a tween call. */ -function updateAnimationSelectorInScript( - script: string, - animationId: string, - newSelector: string, -): string { - const parsed = parseGsapScriptAcornForWrite(script); - if (!parsed) return script; - const target = parsed.located.find((l) => l.id === animationId); - if (!target) return script; - const selectorArg = target.call.node.arguments?.[0]; - if (!selectorArg) return script; - const ms = new MagicString(script); - ms.overwrite(selectorArg.start, selectorArg.end, JSON.stringify(newSelector)); - return ms.toString(); -} - -/** - * Insert a `tl.set()` call immediately after the timeline declaration - * (before existing tweens) to establish inherited state on a new element. - */ -function insertInheritedStateSetInScript( - script: string, - selector: string, - position: number, - properties: Record, -): string { - const parsed = parseGsapScriptAcornForWrite(script); - if (!parsed) return script; - const props = Object.entries(properties) - .map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`) - .join(", "); - const code = `${parsed.timelineVar}.set(${JSON.stringify(selector)}, { ${props} }, ${position});`; - const ms = new MagicString(script); - const tlDecl = findTimelineDeclarationStatement(parsed.ast, parsed.timelineVar); - const firstLocated = parsed.located[0]; - if (tlDecl) { - ms.appendLeft(tlDecl.end, "\n" + code); - } else if (firstLocated) { - const firstCall = firstLocated.call; - const exprStmt = findEnclosingExpressionStatement(firstCall.ancestors); - const insertAt = exprStmt?.start ?? firstCall.node.start; - ms.prependLeft(insertAt, code + "\n"); - } else { - ms.append("\n" + code); - } - return ms.toString(); -} - -/** - * Compute, in forward (timeline) order, the inherited-props baseline available - * BEFORE each matching tween, plus the final cumulative state at the split point. - * A tween contributes to later baselines when it ends at/before the split (full - * props or last keyframe), spans the split via keyframes (kfs at/before split), - * or spans the split as a flat tween (its interpolated midpoint). Decoupled from - * the reverse write loop so the spanning-tween midpoint reads earlier tweens. - */ -// fallow-ignore-next-line complexity -function computeForwardBaselines( - matching: GsapAnimation[], - splitTime: number, -): { before: Array>; final: Record } { - const before: Array> = []; - const acc: Record = {}; - for (const anim of matching) { - before.push({ ...acc }); - const pos = typeof anim.position === "number" ? anim.position : 0; - const dur = anim.duration ?? 0; - const animEnd = pos + dur; - - if (anim.keyframes) { - const kfs = anim.keyframes.keyframes; - if (pos >= splitTime) { - // Moves wholly to the new element — contributes nothing to the baseline. - } else if (animEnd > splitTime) { - for (const kf of kfs) { - const kfTime = pos + (kf.percentage / 100) * dur; - if (kfTime <= splitTime) { - for (const [k, v] of Object.entries(kf.properties)) acc[k] = v; - } - } - } else { - const lastKf = kfs[kfs.length - 1]; - if (lastKf) { - for (const [k, v] of Object.entries(lastKf.properties)) acc[k] = v; - } - } - continue; - } - - if (animEnd <= splitTime) { - for (const [k, v] of Object.entries(anim.properties)) acc[k] = v; - continue; - } - - if (pos >= splitTime) continue; - - // Flat tween spanning the split — its midpoint becomes the inherited value. - const progress = dur > 0 ? (splitTime - pos) / dur : 0; - const fromSource = anim.fromProperties ?? acc; - for (const [k, v] of Object.entries(anim.properties)) { - if (typeof v !== "number") { - acc[k] = v; - continue; - } - const fromVal = typeof fromSource[k] === "number" ? (fromSource[k] as number) : 0; - acc[k] = fromVal + (v - fromVal) * progress; - } - } - return { before, final: { ...acc } }; -} - -// Split one tween that straddles the split point: trim the original to the -// first half (interpolated midpoint as its new end) and add a fromTo for the -// second half on the new element. `fromSource` is the forward baseline. -function buildSpanningSplit( - result: string, - anim: GsapAnimation, - pos: number, - dur: number, - fromSource: Record, - ctx: { splitTime: number; newSelector: string; newElementStart: number }, -): string { - const progress = dur > 0 ? (ctx.splitTime - pos) / dur : 0; - const midProps: Record = {}; - for (const [k, v] of Object.entries(anim.properties)) { - if (typeof v !== "number") { - midProps[k] = v; - continue; - } - const fromVal = typeof fromSource[k] === "number" ? (fromSource[k] as number) : 0; - midProps[k] = fromVal + (v - fromVal) * progress; - } - const trimmed = updateAnimationInScript(result, anim.id, { - duration: ctx.splitTime - pos, - properties: midProps, - }); - return addAnimationToScript(trimmed, { - targetSelector: ctx.newSelector, - method: "fromTo", - position: ctx.newElementStart, - duration: pos + dur - ctx.splitTime, - properties: { ...anim.properties }, - fromProperties: { ...midProps }, - ease: anim.ease, - extras: anim.extras, - }).script; -} - -type SplitCtx = { - splitTime: number; - originalSelector: string; - newSelector: string; - newElementStart: number; -}; - -// Decide what one matching tween does at the split point: move to the new -// element (wholly after), stay (wholly before / keyframes before), get skipped -// (keyframes spanning), or get interpolated in half (spanning). Returns the -// updated script; pushes any skip reason into `skippedSelectors`. -function applyTweenSplit( - result: string, - anim: GsapAnimation, - baselineBefore: Record, - ctx: SplitCtx, - skippedSelectors: string[], -): string { - const pos = typeof anim.position === "number" ? anim.position : 0; - const dur = anim.duration ?? 0; - const animEnd = pos + dur; - - if (anim.keyframes) { - if (pos >= ctx.splitTime) - return updateAnimationSelectorInScript(result, anim.id, ctx.newSelector); - if (animEnd > ctx.splitTime) { - skippedSelectors.push(`${ctx.originalSelector} (keyframes spanning split)`); - } - // Inherited-state for kf tweens is handled by computeForwardBaselines. - return result; - } - // Wholly before the split — kept on the original element. - if (animEnd <= ctx.splitTime) return result; - // Wholly after — move to the new element. - if (pos >= ctx.splitTime) - return updateAnimationSelectorInScript(result, anim.id, ctx.newSelector); - // Spans the split — interpolate the midpoint from the FORWARD baseline. - const fromSource = anim.fromProperties ?? baselineBefore; - return buildSpanningSplit(result, anim, pos, dur, fromSource, ctx); -} - -export function splitAnimationsInScript( - script: string, - opts: SplitAnimationsOptions, -): SplitAnimationsResult { - const parsed = parseGsapScriptAcornForWrite(script); - if (!parsed) return { script, skippedSelectors: [] }; - - const originalSelector = `#${opts.originalId}`; - const newSelector = `#${opts.newId}`; - - const animations = parsed.located.map((l) => l.animation); - const skippedSelectors: string[] = []; - - for (const a of animations) { - if (a.targetSelector !== originalSelector && a.targetSelector.includes(opts.originalId)) { - skippedSelectors.push(a.targetSelector); - } - } - - const matching = animations.filter((a) => a.targetSelector === originalSelector); - if (matching.length === 0) return { script, skippedSelectors }; - - let result = script; - const newElementStart = opts.splitTime; - - // Forward pre-pass: compute the inherited-props baseline available BEFORE each - // matching tween, in source/timeline order. The write loop below runs in - // REVERSE (so updateAnimationSelectorInScript's selector edits can't shift the - // count-based IDs of not-yet-processed tweens), but the spanning-tween midpoint - // interpolation needs the baseline from EARLIER tweens — which a reverse - // accumulator hasn't seen yet. Decoupling the two fixes the wrong midpoint. - const { before: baselineBefore, final: finalInheritedProps } = computeForwardBaselines( - matching, - opts.splitTime, - ); - - // Reverse iteration: updateAnimationSelectorInScript mutates selectors which - // can shift count-based ID suffixes for later animations. - const ctx = { splitTime: opts.splitTime, originalSelector, newSelector, newElementStart }; - for (let i = matching.length - 1; i >= 0; i--) { - const anim = matching[i]; - if (!anim) continue; - result = applyTweenSplit(result, anim, baselineBefore[i] ?? {}, ctx, skippedSelectors); - } - - if (Object.keys(finalInheritedProps).length > 0) { - result = insertInheritedStateSetInScript( - result, - newSelector, - newElementStart, - finalInheritedProps, - ); - } - - return { script: result, skippedSelectors }; -} - -// ── Unroll dynamic animations ──────────────────────────────────────────────── - -function isLoopNode(node: Node): boolean { - const t = node?.type; - return ( - t === "ForStatement" || - t === "ForInStatement" || - t === "ForOfStatement" || - t === "WhileStatement" - ); -} - -function isForEachStatement(node: Node): boolean { - return ( - node?.type === "ExpressionStatement" && - node.expression?.type === "CallExpression" && - node.expression.callee?.property?.name === "forEach" - ); -} - -/** The nearest enclosing loop / forEach AST node (not just its byte range). */ -function findEnclosingLoopNode(ancestors: Node[]): Node | null { - for (let i = ancestors.length - 2; i >= 0; i--) { - const node = ancestors[i]; - if (isLoopNode(node) || isForEachStatement(node)) return node; - } - return null; -} - -/** Statements making up a loop's body block, or null when not a simple block. */ -function loopBodyStatements(loopNode: Node): Node[] | null { - let body: Node; - if (loopNode?.type === "ExpressionStatement") { - // forEach(cb): body is the callback's block. - const cb = loopNode.expression?.arguments?.[0]; - body = cb?.body; - } else { - body = loopNode?.body; - } - if (body?.type !== "BlockStatement") return null; - return (body.body ?? []).filter((s: Node) => s?.type === "ExpressionStatement"); -} - -/** The loop's index identifier name (`for (let i …)`), used for per-iteration substitution. */ -function loopIndexVarName(loopNode: Node): string | null { - if (loopNode?.type === "ForStatement") { - const decl = loopNode.init?.declarations?.[0]; - return typeof decl?.id?.name === "string" ? decl.id.name : null; - } - return null; -} - -/** - * Rewrite one body statement's source for iteration `idx`: replace USES of the - * loop index variable (AST Identifier nodes) with the literal index. AST-based, - * not a text regex, so the index name appearing inside a string literal (e.g. a - * selector ".row-i") or as a non-computed member/key (`obj.i`, `{ i: … }`) is - * left untouched — only real references to the variable are substituted. - */ -// An identifier in "binding position" is a name, not a value reference: a -// non-computed member property (`obj.i`) or object-literal key (`{ i: … }`). -// Those must NOT be substituted with the iteration index. -function isIndexBindingPosition(node: Node, parent: Node): boolean { - if (parent?.type === "MemberExpression") return parent.property === node && !parent.computed; - if (parent?.type === "Property" || parent?.type === "ObjectProperty") { - return parent.key === node && !parent.computed; - } - return false; -} - -function substituteLoopIndex(stmt: Node, indexVar: string, idx: number, script: string): string { - const base = stmt.start as number; - const src = script.slice(base, stmt.end as number); - const ranges: Array<[number, number]> = []; - acornWalk.ancestor(stmt, { - Identifier(node: Node, _state: unknown, ancestors: Node[]) { - if (node.name !== indexVar) return; - if (isIndexBindingPosition(node, ancestors[ancestors.length - 2])) return; - ranges.push([(node.start as number) - base, (node.end as number) - base]); - }, - }); - if (ranges.length === 0) return src; - ranges.sort((a, b) => b[0] - a[0]); - let out = src; - for (const [s, e] of ranges) out = out.slice(0, s) + String(idx) + out.slice(e); - return out; -} - -function buildUnrollReplacement( - timelineVar: string, - animation: GsapAnimation, - elements: Array<{ - selector: string; - keyframes: Array<{ percentage: number; properties: Record }>; - easeEach?: string; - }>, -): string { - const duration = typeof animation.duration === "number" ? animation.duration : 8; - const ease = typeof animation.ease === "string" ? animation.ease : "none"; - const pos = animation.position ?? 0; - const posCode = typeof pos === "number" ? String(pos) : JSON.stringify(pos); - const calls = elements.map((el) => { - const sorted = [...el.keyframes].sort((a, b) => a.percentage - b.percentage); - const kfCode = buildKeyframeObjectCode(sorted, el.easeEach); - return `${timelineVar}.to(${JSON.stringify(el.selector)}, { keyframes: ${kfCode}, duration: ${duration}, ease: ${JSON.stringify(ease)} }, ${posCode});`; - }); - return calls.join("\n "); -} - -export type UnrollElement = { - selector: string; - keyframes: Array<{ percentage: number; properties: Record }>; - easeEach?: string; -}; - -/** Build one element's unrolled `tl.to(...)` call from the target animation. */ -function buildUnrollCallForElement( - timelineVar: string, - animation: GsapAnimation, - el: UnrollElement, -): string { - const duration = typeof animation.duration === "number" ? animation.duration : 8; - const ease = typeof animation.ease === "string" ? animation.ease : "none"; - const pos = animation.position ?? 0; - const posCode = typeof pos === "number" ? String(pos) : JSON.stringify(pos); - const sorted = [...el.keyframes].sort((a, b) => a.percentage - b.percentage); - const kfCode = buildKeyframeObjectCode(sorted, el.easeEach); - return `${timelineVar}.to(${JSON.stringify(el.selector)}, { keyframes: ${kfCode}, duration: ${duration}, ease: ${JSON.stringify(ease)} }, ${posCode});`; -} - -/** Sentinel: the unroll cannot safely reproduce the loop body — caller no-ops. */ -const REFUSE_UNROLL = Symbol("refuse-unroll"); - -/** Every statement in a loop's body block (unfiltered), or [] when not a block. */ -function loopBodyRawStatements(loopNode: Node): Node[] { - const body = - loopNode?.type === "ExpressionStatement" - ? loopNode.expression?.arguments?.[0]?.body - : loopNode?.body; - return body?.type === "BlockStatement" ? (body.body ?? []) : []; -} - -/** A node that re-binds `indexVar`: a re-declaration or a function param. */ -function rebindsIndex(node: Node, indexVar: string): boolean { - if (node.type === "VariableDeclarator") return node.id?.name === indexVar; - if ( - node.type === "FunctionExpression" || - node.type === "FunctionDeclaration" || - node.type === "ArrowFunctionExpression" - ) { - return (node.params ?? []).some((p: Node) => p?.name === indexVar); - } - return false; -} - -/** Object shorthand `{ i }` — substituting the value would yield invalid `{ 0 }`. */ -function isShorthandIndexUse(node: Node, indexVar: string): boolean { - return ( - (node.type === "Property" || node.type === "ObjectProperty") && - node.shorthand === true && - propKeyName(node) === indexVar - ); -} - -/** - * A sibling statement can't be safely index-substituted when it re-binds the - * loop index (shadowing — a nested `for (let i …)`, a callback param `i`) or - * uses it in object shorthand (`{ i }`, which would splice to the invalid - * `{ 0 }`). substituteLoopIndex has no scope analysis, so in these cases it - * would emit broken or wrong code — the unroll must refuse instead. - */ -function hasUnsafeLoopIndexUse(stmt: Node, indexVar: string): boolean { - let unsafe = false; - acornWalk.full(stmt, (node: Node) => { - if (!unsafe && (isShorthandIndexUse(node, indexVar) || rebindsIndex(node, indexVar))) { - unsafe = true; - } - }); - return unsafe; -} - -/** How to handle the loop body's non-target siblings when unrolling. */ -function unrollSiblingStrategy( - loopNode: Node, - targetStmt: Node, - stmts: Node[], - indexVar: string | null, -): "blanket" | "refuse" | "preserve" { - const siblings = stmts.filter((s) => s !== targetStmt); - // A sibling the filtered statement list doesn't model (non-ExpressionStatement) - // would be silently lost by either path — refuse if any exists. - const hasUnmodeledSibling = loopBodyRawStatements(loopNode).some( - (s) => s !== targetStmt && !stmts.includes(s), - ); - if (siblings.length === 0 && !hasUnmodeledSibling) return "blanket"; - if (hasUnmodeledSibling || !indexVar) return "refuse"; - return siblings.some((s) => hasUnsafeLoopIndexUse(s, indexVar)) ? "refuse" : "preserve"; -} - -/** Emit the per-iteration unrolled lines (target → static tl.to, siblings → index-substituted). */ -function emitUnrolledLines( - stmts: Node[], - targetStmt: Node, - elements: UnrollElement[], - timelineVar: string, - animation: GsapAnimation, - indexVar: string, - script: string, -): string { - const lines: string[] = []; - for (let idx = 0; idx < elements.length; idx++) { - const el = elements[idx]; - if (!el) continue; - for (const stmt of stmts) { - lines.push( - stmt === targetStmt - ? buildUnrollCallForElement(timelineVar, animation, el) - : substituteLoopIndex(stmt, indexVar, idx, script), - ); - } - } - return lines.join("\n "); -} - -/** - * Unroll the loop body, preserving every statement that is NOT the target tween. - * For each iteration, emit each non-target statement with the loop index - * substituted (e.g. `tl.set(items[i], …)` → `tl.set(items[0], …)`), and replace - * the target tween statement with that element's static `tl.to()` call. - * - * Returns null when a blanket overwrite is lossless (no sibling statements), and - * REFUSE_UNROLL when siblings exist but can't be safely reproduced — a non-`for` - * loop (no numeric index to splice), a statement we don't model, or an unsafe - * index use (shadowing / shorthand). Refusing no-ops the unroll, which is safe: - * the dynamic loop keeps rendering correctly, just un-flattened. - */ -function buildLoopUnrollPreserving( - script: string, - timelineVar: string, - animation: GsapAnimation, - elements: UnrollElement[], - loopNode: Node, - targetStmt: Node, -): string | null | typeof REFUSE_UNROLL { - const stmts = loopBodyStatements(loopNode); - if (!stmts || !stmts.includes(targetStmt)) return null; - const indexVar = loopIndexVarName(loopNode); - const strategy = unrollSiblingStrategy(loopNode, targetStmt, stmts, indexVar); - if (strategy === "blanket") return null; - if (strategy === "refuse" || !indexVar) return REFUSE_UNROLL; - return emitUnrolledLines(stmts, targetStmt, elements, timelineVar, animation, indexVar, script); -} - -/** - * Replace a dynamic loop that generates multiple tween calls with individual - * static `tl.to()` calls — one per element. Finds the loop containing the - * animation and replaces the loop with unrolled static calls, preserving every - * non-target statement in the loop body per iteration. - */ -export function unrollDynamicAnimations( - script: string, - animationId: string, - elements: UnrollElement[], -): string { - // An empty element list has no unrolled form — replacing the loop/statement - // with zero calls would silently delete the animation. No-op instead. - if (elements.length === 0) return script; - const parsed = parseGsapScriptAcornForWrite(script); - if (!parsed) return script; - const target = parsed.located.find((l) => l.id === animationId); - if (!target) return script; - - const ms = new MagicString(script); - const loopNode = findEnclosingLoopNode(target.call.ancestors); - if (loopNode) { - const targetStmt = findEnclosingExpressionStatement(target.call.ancestors); - const preserving = targetStmt - ? buildLoopUnrollPreserving( - script, - parsed.timelineVar, - target.animation, - elements, - loopNode, - targetStmt, - ) - : null; - // Siblings exist but can't be safely reproduced — leave the loop untouched - // rather than drop or corrupt them. The op no-ops (before === after). - if (preserving === REFUSE_UNROLL) return script; - // Fall back to the simple whole-body replacement when the body isn't a plain - // block of statements we can preserve. - const replacement = - preserving ?? buildUnrollReplacement(parsed.timelineVar, target.animation, elements); - ms.overwrite(loopNode.start as number, loopNode.end as number, replacement); - } else { - const stmt = findEnclosingExpressionStatement(target.call.ancestors); - if (!stmt) return script; - const replacement = buildUnrollReplacement(parsed.timelineVar, target.animation, elements); - ms.overwrite(stmt.start as number, stmt.end as number, replacement); - } - return ms.toString(); -} +/** @deprecated Import from @hyperframes/parsers/gsap-writer-acorn */ +export * from "@hyperframes/parsers/gsap-writer-acorn"; diff --git a/packages/core/src/parsers/hfIds.ts b/packages/core/src/parsers/hfIds.ts index a7f2831c91..c4e7fa5f16 100644 --- a/packages/core/src/parsers/hfIds.ts +++ b/packages/core/src/parsers/hfIds.ts @@ -1,132 +1,2 @@ -/** - * Stable hf- element id minting (R1). Node-safe (linkedom only, not browser DOM). - * - * Two surfaces share these helpers: - * - ensureHfIds(html): node-id surface — mints data-hf-id on every element. - * - mintHfId(el, assigned): shared by htmlParser for clip ids. - * - * Hash is CONTENT ONLY (tag + sorted attrs + own text) — no sibling position, - * so inserting a non-identical sibling never shifts another element's id. - */ -import { parseHTML } from "linkedom"; - -// Non-editable / non-visual elements that should never receive a stable id. -export const EXCLUDED_TAGS = new Set([ - "script", - "style", - "template", - "meta", - "link", - "noscript", - "base", -]); - -// 32-bit FNV-1a. Pure, deterministic, no crypto, no Math.random. -function fnv1a(str: string): number { - let h = 0x811c9dc5; - for (let i = 0; i < str.length; i++) { - h ^= str.charCodeAt(i); - h = Math.imul(h, 0x01000193); - } - return h >>> 0; -} - -// 4 base-36 chars · 36^4 ≈ 1.68M ids per document. Birthday-paradox collision -// ≈ N²/(2·36^4): well under 1% per document after dup rehash at realistic -// clip-model sizes (≤ a few hundred elements). The dup-rehash in mintHfId -// resolves the rare collision; width is deliberately small for readable ids. -function toHfId(hash: number): string { - const s = (hash >>> 0).toString(36); - // Use suffix (most-avalanched bits) for better distribution within the 4-char window. - const four = s.length >= 4 ? s.slice(-4) : s.padStart(4, "0"); - return `hf-${four}`; -} - -// Element's own direct text (TEXT_NODE children), not descendants'. -function ownText(el: Element): string { - let text = ""; - el.childNodes.forEach((n) => { - if (n.nodeType === 3) text += (n as Text).nodeValue ?? ""; - }); - return text.trim(); -} - -function contentKey(el: Element): string { - // Exclude all data-hf-* attrs (ids, studio state) — they must not influence the hash. - // Use \x00 / \x01 separators (invalid in HTML attrs) to prevent ambiguous serialization. - const attrs = Array.from(el.attributes) - .filter((a) => !a.name.startsWith("data-hf-")) - .map((a) => `${a.name}\x00${a.value}`) - .sort() - .join("\x01"); - return `${el.tagName.toLowerCase()}|${attrs}|${ownText(el)}`; -} - -/** - * Collision tiebreak for byte-identical siblings: document-order dup counter - * (`hash(key#N)`). This IS order-dependent — two identical `` - * get different ids based on which comes first in the DOM. This is unavoidable: - * unique ids for byte-identical elements require a positional signal. - * - * Why this is safe in practice: once `ensureHfIds` write-back persists - * `data-hf-id` to source the attribute is physically bound to its element. - * Reordering identical siblings carries the attribute along → zero - * order-dependence post-persist. `ensureHfIds` skips pinned elements - * (`if (el.getAttribute("data-hf-id")) continue`), so normal operation - * never re-exposes the ordering after first persist. - */ -// WIRE CONTRACT: id minting is content-keyed (FNV1a of innerHTML + tag). R7's -// preview route relies on mintHfId producing identical ids across mint contexts -// (disk-persist pass vs. in-memory bundle pass) — see preview.test.ts -// "bundle returning untagged HTML gets same ids as disk". Any change that adds -// positional, session, or random input to the hash breaks that invariant and -// makes hf- ids diverge between disk and served HTML, silently corrupting -// drag-to-edit targeting. -export function mintHfId(el: Element, assigned: Set): string { - const key = contentKey(el); - let id = toHfId(fnv1a(key)); - let dup = 0; - while (assigned.has(id)) { - dup += 1; - // Graceful fallback instead of a hard throw: rehashing only fails to find a - // free 4-char slot in a pathological document (~1.6M identical elements). - // Rather than crash the whole parse, widen the id with the dup counter — - // still deterministic and unique, just longer than the 4-char norm. - if (dup > 10000) { - id = `hf-${(fnv1a(key) >>> 0).toString(36)}-${dup}`; - break; - } - id = toHfId(fnv1a(`${key}#${dup}`)); - } - assigned.add(id); - return id; -} - -export function ensureHfIds(html: string): string { - // Mirror parseSourceDocument's fragment-wrapping so bare fragments don't land - // outside in linkedom, which would cause body.querySelectorAll to return []. - const hasDocumentShell = /]/i.test(html); - const wrapped = !hasDocumentShell; - const { document } = wrapped - ? parseHTML(`${html}`) - : parseHTML(html); - const body = document.body; - if (!body) return html; - - const assigned = new Set(); - // Seed with already-present ids (pin) so fresh mints never collide with them. - // Scope to to match the mint walk below — a stray data-hf-id in - // must not pin an id into the set that a body element would then be bumped off. - for (const el of Array.from(body.querySelectorAll("[data-hf-id]"))) { - const existing = el.getAttribute("data-hf-id"); - if (existing) assigned.add(existing); - } - - for (const el of Array.from(body.querySelectorAll("*"))) { - if (EXCLUDED_TAGS.has(el.tagName.toLowerCase())) continue; - if (el.getAttribute("data-hf-id")) continue; // pinned - el.setAttribute("data-hf-id", mintHfId(el, assigned)); - } - - return wrapped ? document.body.innerHTML || "" : document.toString(); -} +/** @deprecated Import from @hyperframes/parsers/hf-ids */ +export * from "@hyperframes/parsers/hf-ids"; diff --git a/packages/core/src/parsers/htmlParser.ts b/packages/core/src/parsers/htmlParser.ts index 146af3f206..c405535b25 100644 --- a/packages/core/src/parsers/htmlParser.ts +++ b/packages/core/src/parsers/htmlParser.ts @@ -1,861 +1,10 @@ -import type { - TimelineElement, - TimelineElementType, - TimelineMediaElement, - TimelineTextElement, - TimelineCompositionElement, - CanvasResolution, - Keyframe, - KeyframeProperties, - StageZoomKeyframe, - CompositionVariable, -} from "../core.types"; -import { validateCompositionGsap } from "./gsapSerialize"; -import { ensureHfIds } from "./hfIds.js"; -import { parseGsapScriptAcornForWrite } from "./gsapParserAcorn.js"; -import { queryByAttr } from "../utils/cssSelector"; -import { removeAnimationFromScript } from "./gsapWriterAcorn.js"; -import type { ValidationResult } from "../core.types"; - -const MEDIA_TYPES = new Set(["video", "image", "audio"]); - -export interface ParsedHtml { - elements: TimelineElement[]; - gsapScript: string | null; - styles: string | null; - resolution: CanvasResolution; - keyframes: Record; - stageZoomKeyframes: StageZoomKeyframe[]; -} - -function getElementType(el: Element): TimelineElementType | null { - const tag = el.tagName.toLowerCase(); - if (tag === "video") return "video"; - if (tag === "img") return "image"; - if (tag === "audio") return "audio"; - // Check for explicit data-type attribute first - const dataType = el.getAttribute("data-type"); - if (dataType === "composition") return "composition"; - if (dataType === "text") return "text"; - // Fall back to tag-based detection for backwards compatibility - if ( - tag === "div" || - tag === "p" || - tag === "h1" || - tag === "h2" || - tag === "h3" || - tag === "span" - ) { - return "text"; - } - return null; -} - -function getElementName(el: Element): string { - const dataName = el.getAttribute("data-name"); - if (dataName) return dataName; - - const type = getElementType(el); - if (type === "text") { - const text = el.textContent?.trim().slice(0, 30) || "Text"; - return text.length === 30 ? text + "..." : text; - } - - const src = el.getAttribute("src"); - if (src) { - const filename = src.split("/").pop() || src; - return filename.split("?")[0] ?? filename; - } - - return el.id || el.className?.toString().split(" ")[0] || "Element"; -} - -function getZIndex(el: Element): number { - const dataLayer = el.getAttribute("data-layer"); - if (dataLayer) return parseInt(dataLayer, 10) || 0; - - const style = (el as HTMLElement).style?.zIndex; - if (style) return parseInt(style, 10) || 0; - - return 0; -} - -function parseResolutionFromCss(doc: Document, cssText: string | null): CanvasResolution { - const stage = doc.getElementById("stage") || doc.querySelector("#stage"); - if (stage) { - const inlineStyle = (stage as HTMLElement).style; - if (inlineStyle?.width && inlineStyle?.height) { - const w = parseInt(inlineStyle.width, 10); - const h = parseInt(inlineStyle.height, 10); - if (w && h) { - return resolveResolutionFromDimensions(w, h); - } - } - } - - if (cssText) { - const stageMatch = cssText.match( - /#stage\s*\{[^}]*width:\s*(\d+)px[^}]*height:\s*(\d+)px[^}]*\}/, - ); - if (stageMatch) { - const w = parseInt(stageMatch[1] ?? "", 10); - const h = parseInt(stageMatch[2] ?? "", 10); - return resolveResolutionFromDimensions(w, h); - } - const stageMatchReverse = cssText.match( - /#stage\s*\{[^}]*height:\s*(\d+)px[^}]*width:\s*(\d+)px[^}]*\}/, - ); - if (stageMatchReverse) { - const h = parseInt(stageMatchReverse[1] ?? "", 10); - const w = parseInt(stageMatchReverse[2] ?? "", 10); - return resolveResolutionFromDimensions(w, h); - } - } - - return "portrait"; -} - -function parseResolutionFromHtml(doc: Document): CanvasResolution | null { - const htmlEl = doc.documentElement; - const resolutionAttr = htmlEl.getAttribute("data-resolution"); - if ( - resolutionAttr === "landscape" || - resolutionAttr === "portrait" || - resolutionAttr === "landscape-4k" || - resolutionAttr === "portrait-4k" || - resolutionAttr === "square" || - resolutionAttr === "square-4k" - ) { - return resolutionAttr; - } - - const widthAttr = htmlEl.getAttribute("data-composition-width"); - const heightAttr = htmlEl.getAttribute("data-composition-height"); - if (widthAttr && heightAttr) { - const width = parseInt(widthAttr, 10); - const height = parseInt(heightAttr, 10); - if (width && height) { - return resolveResolutionFromDimensions(width, height); - } - } - - return null; -} - -const UHD_SQUARE_MIN = 2160; -const UHD_RECT_MIN = 3840; - -function resolveResolutionFromDimensions(width: number, height: number): CanvasResolution { - const longSide = Math.max(width, height); - if (width === height) { - return longSide >= UHD_SQUARE_MIN ? "square-4k" : "square"; - } - const isLandscape = width > height; - const isUhd = longSide >= UHD_RECT_MIN; - if (isLandscape) return isUhd ? "landscape-4k" : "landscape"; - return isUhd ? "portrait-4k" : "portrait"; -} - -export function parseHtml(html: string): ParsedHtml { - const withIds = ensureHfIds(html); - const parser = new DOMParser(); - const doc = parser.parseFromString(withIds, "text/html"); - - const elements: TimelineElement[] = []; - const keyframes: Record = {}; - let idCounter = 0; - - const htmlEl = doc.documentElement; - const customStylesAttr = htmlEl.getAttribute("data-custom-styles"); - let customStyles: string | null = null; - if (customStylesAttr) { - try { - customStyles = JSON.parse(customStylesAttr); - } catch { - customStyles = customStylesAttr; - } - } - - const timedElements = doc.querySelectorAll("[data-start]"); - - timedElements.forEach((el) => { - const type = getElementType(el); - if (!type) return; - - const start = parseFloat(el.getAttribute("data-start") || "0"); - const dataEnd = el.getAttribute("data-end"); - - let duration: number; - if (dataEnd) { - duration = Math.max(0, parseFloat(dataEnd) - start); - } else { - duration = 5; - } - - // R1: stable hf- id minted by ensureHfIds above; clips just read it. - // Legacy/migration note: ensureHfIds pins a pre-existing `data-hf-id`, and - // the generator emits `data-hf-id="${element.id}"`. So a clip authored - // before R1 with `id="my-title"` round-trips as `data-hf-id="my-title"` — - // a non-`hf-`-shaped but still stable, exact-match handle. This is safe - // indefinitely: targeting uses exact `[data-hf-id="…"]` match (it does not - // require the hf- prefix). ensureHfIds skips elements that already carry - // data-hf-id, so legacy values are NOT re-minted automatically — they - // persist until the user re-saves the composition through Studio. Not a bug. - const id = el.getAttribute("data-hf-id") || el.id || `element-${++idCounter}`; - const name = getElementName(el); - const zIndex = getZIndex(el); - - // Parse data-keyframes attribute if present - const keyframesAttr = el.getAttribute("data-keyframes"); - if (keyframesAttr) { - try { - const parsedKeyframes = JSON.parse(keyframesAttr); - if (Array.isArray(parsedKeyframes) && parsedKeyframes.length > 0) { - keyframes[id] = parsedKeyframes; - } - } catch { - // skip invalid keyframes - } - } - - // Parse transform properties (x, y, scale, opacity) - const xAttr = el.getAttribute("data-x"); - const yAttr = el.getAttribute("data-y"); - const scaleAttr = el.getAttribute("data-scale"); - const opacityAttr = el.getAttribute("data-opacity"); - const x = xAttr ? parseFloat(xAttr) : undefined; - const y = yAttr ? parseFloat(yAttr) : undefined; - const scale = scaleAttr ? parseFloat(scaleAttr) : undefined; - const opacity = opacityAttr ? parseFloat(opacityAttr) : undefined; - - if (type === "text") { - const textEl = el.firstElementChild; - const content = textEl?.textContent || name; - const color = el.getAttribute("data-color") || undefined; - const fontSizeAttr = el.getAttribute("data-font-size"); - const fontSize = fontSizeAttr ? parseInt(fontSizeAttr, 10) : undefined; - const fontWeightAttr = el.getAttribute("data-font-weight"); - const fontWeight = fontWeightAttr ? parseInt(fontWeightAttr, 10) : undefined; - const fontFamily = el.getAttribute("data-font-family") || undefined; - const textShadowAttr = el.getAttribute("data-text-shadow"); - const textShadow = textShadowAttr === "false" ? false : undefined; - - // Parse outline properties - const textOutlineAttr = el.getAttribute("data-text-outline"); - const textOutline = textOutlineAttr === "true" ? true : undefined; - const textOutlineColor = el.getAttribute("data-text-outline-color") || undefined; - const textOutlineWidthAttr = el.getAttribute("data-text-outline-width"); - const textOutlineWidth = textOutlineWidthAttr - ? parseInt(textOutlineWidthAttr, 10) - : undefined; - - // Parse highlight properties - const textHighlightAttr = el.getAttribute("data-text-highlight"); - const textHighlight = textHighlightAttr === "true" ? true : undefined; - const textHighlightColor = el.getAttribute("data-text-highlight-color") || undefined; - const textHighlightPaddingAttr = el.getAttribute("data-text-highlight-padding"); - const textHighlightPadding = textHighlightPaddingAttr - ? parseInt(textHighlightPaddingAttr, 10) - : undefined; - const textHighlightRadiusAttr = el.getAttribute("data-text-highlight-radius"); - const textHighlightRadius = textHighlightRadiusAttr - ? parseInt(textHighlightRadiusAttr, 10) - : undefined; - - const textElement: TimelineTextElement = { - id, - type: "text", - name, - content, - startTime: start, - duration, - zIndex, - x, - y, - scale, - opacity, - color, - fontSize, - fontWeight, - fontFamily, - textShadow, - textOutline, - textOutlineColor, - textOutlineWidth, - textHighlight, - textHighlightColor, - textHighlightPadding, - textHighlightRadius, - }; - elements.push(textElement); - } else if (type === "composition") { - // Composition is a div container with iframe inside - const iframe = el.querySelector("iframe"); - const src = iframe?.getAttribute("src") || el.getAttribute("src") || ""; - const compositionId = el.getAttribute("data-composition-id") || ""; - const sourceDurationAttr = el.getAttribute("data-source-duration"); - const sourceDuration = sourceDurationAttr ? parseFloat(sourceDurationAttr) : undefined; - const sourceWidthAttr = el.getAttribute("data-source-width"); - const sourceWidth = sourceWidthAttr ? parseInt(sourceWidthAttr, 10) : undefined; - const sourceHeightAttr = el.getAttribute("data-source-height"); - const sourceHeight = sourceHeightAttr ? parseInt(sourceHeightAttr, 10) : undefined; - - // Parse variable values if present - const variableValuesAttr = el.getAttribute("data-variable-values"); - let variableValues: Record | undefined; - if (variableValuesAttr) { - try { - variableValues = JSON.parse(variableValuesAttr); - } catch { - // skip invalid variable values - } - } - - const compositionElement: TimelineCompositionElement = { - id, - type: "composition", - name, - src, - compositionId, - startTime: start, - duration, - zIndex, - x, - y, - scale, - opacity, - sourceDuration, - sourceWidth, - sourceHeight, - variableValues, - }; - elements.push(compositionElement); - } else { - if (!MEDIA_TYPES.has(type)) return; - - const src = el.getAttribute("src") || ""; - const mediaStartTimeAttr = el.getAttribute("data-media-start"); - const mediaStartTime = mediaStartTimeAttr ? parseFloat(mediaStartTimeAttr) : undefined; - const sourceDurationAttr = el.getAttribute("data-source-duration"); - const sourceDuration = sourceDurationAttr ? parseFloat(sourceDurationAttr) : undefined; - const isArollAttr = el.getAttribute("data-aroll"); - const isAroll = isArollAttr === "true" ? true : undefined; - const volumeAttr = el.getAttribute("data-volume"); - const volume = volumeAttr ? parseFloat(volumeAttr) : undefined; - const hasAudioAttr = el.getAttribute("data-has-audio"); - const hasAudio = hasAudioAttr === "true" ? true : undefined; - - const mediaElement: TimelineMediaElement = { - id, - type: type as "video" | "image" | "audio", - name, - src, - startTime: start, - duration, - zIndex, - x, - y, - scale, - opacity, - mediaStartTime, - sourceDuration, - isAroll, - volume, - hasAudio, - }; - elements.push(mediaElement); - } - }); - - const scriptTags = doc.querySelectorAll("script"); - let gsapScript: string | null = null; - - for (const script of scriptTags) { - const src = script.getAttribute("src"); - if (src && src.includes("gsap")) continue; - - const content = script.textContent?.trim(); - if (content && (content.includes("gsap") || content.includes("timeline"))) { - gsapScript = content; - break; - } - } - - // Normalize keyframes (clamp negative time, convert absolute -> relative if detected) - for (const element of elements) { - const elementKeyframes = keyframes[element.id]; - if (!elementKeyframes || elementKeyframes.length === 0) continue; - - const baseX = element.x ?? 0; - const baseY = element.y ?? 0; - const baseScale = - element.type === "video" || element.type === "image" || element.type === "composition" - ? ((element as TimelineMediaElement | TimelineCompositionElement).scale ?? 1) - : 1; - - keyframes[element.id] = normalizeKeyframes(elementKeyframes, baseX, baseY, baseScale); - } - - const styleTags = doc.querySelectorAll("style"); - const allStyles = - Array.from(styleTags) - .map((s) => s.textContent?.trim()) - .filter(Boolean) - .join("\n\n") || null; - - const customStyleTags = Array.from(styleTags).filter( - (s) => s.getAttribute("data-hf-custom") === "true", - ); - const customStylesFromTags = - customStyleTags - .map((s) => s.textContent?.trim()) - .filter(Boolean) - .join("\n\n") || null; - - const styles = customStyles ?? customStylesFromTags ?? null; - - const resolution = parseResolutionFromHtml(doc) ?? parseResolutionFromCss(doc, allStyles); - - // Parse stage zoom keyframes from zoom container - const stageZoomKeyframes = parseStageZoomKeyframes(doc); - - return { - elements, - gsapScript, - styles, - resolution, - keyframes, - stageZoomKeyframes, - }; -} - -function parseStageZoomKeyframes(doc: Document): StageZoomKeyframe[] { - const zoomContainer = doc.getElementById("stage-zoom-container"); - if (!zoomContainer) { - return []; - } - - const zoomKeyframesAttr = zoomContainer.getAttribute("data-zoom-keyframes"); - if (!zoomKeyframesAttr) { - return []; - } - - try { - const parsed = JSON.parse(zoomKeyframesAttr); - if (Array.isArray(parsed)) { - return parsed.filter( - (kf): kf is StageZoomKeyframe => - typeof kf === "object" && - kf !== null && - typeof kf.id === "string" && - typeof kf.time === "number" && - typeof kf.zoom === "object" && - kf.zoom !== null && - typeof kf.zoom.scale === "number" && - typeof kf.zoom.focusX === "number" && - typeof kf.zoom.focusY === "number", - ); - } - } catch { - // skip invalid zoom keyframes - } - - return []; -} - -function normalizeKeyframes( - keyframes: Keyframe[], - baseX: number, - baseY: number, - baseScale: number, -): Keyframe[] { - const timeEpsilon = 0.001; - const valueEpsilon = 0.00001; - - const hasBaseCheck = (value: number | undefined, base: number): boolean => - value !== undefined && Math.abs(value - base) <= valueEpsilon && Math.abs(base) > valueEpsilon; - - const timeZeroKeyframes = keyframes.filter((kf) => Math.abs(kf.time) <= timeEpsilon); - - const treatAsAbsolute = timeZeroKeyframes.some((kf) => { - const props = kf.properties || {}; - if ( - hasBaseCheck(props.x, baseX) || - hasBaseCheck(props.y, baseY) || - (baseScale !== 1 && hasBaseCheck(props.scale, baseScale)) - ) { - return true; - } - return false; - }); - - return keyframes.map((kf) => { - const normalizedProps: Partial = {}; - for (const [key, value] of Object.entries(kf.properties || {})) { - if (typeof value !== "number") continue; - if (treatAsAbsolute && key === "x") { - normalizedProps.x = value - baseX; - } else if (treatAsAbsolute && key === "y") { - normalizedProps.y = value - baseY; - } else if (treatAsAbsolute && key === "scale") { - normalizedProps.scale = baseScale !== 0 ? value / baseScale : value; - } else { - (normalizedProps as Record)[key] = value; - } - } - - return { - ...kf, - time: Math.max(0, kf.time), - properties: normalizedProps, - }; - }); -} - -export function updateElementInHtml( - html: string, - elementId: string, - updates: Partial, -): string { - const parser = new DOMParser(); - const doc = parser.parseFromString(html, "text/html"); - - const el = doc.getElementById(elementId) || queryByAttr(doc, "data-name", elementId); - if (!el) return html; - - if (updates.startTime !== undefined) { - el.setAttribute("data-start", String(updates.startTime)); - if (el.hasAttribute("data-end") && updates.duration !== undefined) { - el.setAttribute("data-end", String(updates.startTime + updates.duration)); - } - } - - if (updates.duration !== undefined) { - const start = parseFloat(el.getAttribute("data-start") || "0"); - el.setAttribute("data-end", String(start + updates.duration)); - el.removeAttribute("data-duration"); // Clean up legacy - } - - if (updates.name !== undefined) { - el.setAttribute("data-name", updates.name); - } - - if (updates.zIndex !== undefined) { - el.setAttribute("data-layer", String(updates.zIndex)); - } - - // Handle media-specific property - if ("src" in updates && updates.src !== undefined) { - el.setAttribute("src", updates.src); - } - - // Handle text-specific properties - if ("content" in updates && updates.content !== undefined) { - const textEl = el.firstElementChild; - if (textEl) { - textEl.textContent = updates.content; - } - } - - if ("color" in updates && updates.color !== undefined) { - el.setAttribute("data-color", updates.color); - } - - if ("fontSize" in updates && updates.fontSize !== undefined) { - el.setAttribute("data-font-size", String(updates.fontSize)); - } - - if ("textShadow" in updates) { - if (updates.textShadow === false) { - el.setAttribute("data-text-shadow", "false"); - } else { - el.removeAttribute("data-text-shadow"); - } - } - - // Handle volume property for audio/video - if ("volume" in updates) { - if (updates.volume !== undefined && updates.volume !== 1) { - el.setAttribute("data-volume", String(updates.volume)); - } else { - el.removeAttribute("data-volume"); - } - } - - // Handle hasAudio property for videos - if ("hasAudio" in updates) { - if (updates.hasAudio === true) { - el.setAttribute("data-has-audio", "true"); - } else { - el.removeAttribute("data-has-audio"); - } - } - - return "\n" + doc.documentElement.outerHTML; -} - -export function addElementToHtml( - html: string, - element: Omit & { id?: string }, -): { html: string; id: string } { - const parser = new DOMParser(); - const doc = parser.parseFromString(html, "text/html"); - - // Prefer zoom container, fall back to stage, then container, then body - const container = - doc.querySelector("#stage-zoom-container") || - doc.querySelector(".container") || - doc.querySelector("#stage") || - doc.body; - - const id = element.id || `element-${Date.now()}`; - - let newEl: Element; - - function applyMediaAttrs(el: Element, mediaEl: TimelineMediaElement): void { - if (mediaEl.src) el.setAttribute("src", mediaEl.src); - if (mediaEl.volume !== undefined && mediaEl.volume !== 1) { - el.setAttribute("data-volume", String(mediaEl.volume)); - } - } - - switch (element.type) { - case "video": { - const mediaEl = element as TimelineMediaElement; - newEl = doc.createElement("video"); - newEl.setAttribute("muted", ""); - newEl.setAttribute("playsinline", ""); - applyMediaAttrs(newEl, mediaEl); - if (mediaEl.hasAudio) { - newEl.setAttribute("data-has-audio", "true"); - } - break; - } - case "image": { - const mediaEl = element as TimelineMediaElement; - newEl = doc.createElement("img"); - if (mediaEl.src) newEl.setAttribute("src", mediaEl.src); - newEl.setAttribute("alt", element.name); - break; - } - case "audio": { - const mediaEl = element as TimelineMediaElement; - newEl = doc.createElement("audio"); - applyMediaAttrs(newEl, mediaEl); - break; - } - case "text": - default: { - const textEl = element as TimelineTextElement; - newEl = doc.createElement("div"); - const textContent = doc.createElement("div"); - textContent.textContent = textEl.content || element.name; - newEl.appendChild(textContent); - if (textEl.color) { - newEl.setAttribute("data-color", textEl.color); - } - if (textEl.fontSize) { - newEl.setAttribute("data-font-size", String(textEl.fontSize)); - } - break; - } - } - - newEl.id = id; - newEl.setAttribute("data-start", String(element.startTime)); - newEl.setAttribute("data-end", String(element.startTime + element.duration)); - newEl.setAttribute("data-layer", String(element.zIndex)); - newEl.setAttribute("data-name", element.name); - - container.appendChild(newEl); - - return { - html: "\n" + doc.documentElement.outerHTML, - id, - }; -} - -function selectorTargetsId(selector: string, id: string): boolean { - return ( - selector === `#${id}` || - selector === `[data-hf-id="${id}"]` || - selector === `[data-hf-id='${id}']` - ); -} - -function stripGsapForId(script: string, elementId: string): string { - // Re-parse after every removal. Animation ids are count-based (positional), so - // removing one tween renumbers the survivors — ids captured from a single - // up-front parse go stale and silently no-op, orphaning later tweens on the - // now-deleted element. Always remove the FIRST still-matching animation in a - // freshly-parsed script until none remain. - let current = script; - for (;;) { - const parsed = parseGsapScriptAcornForWrite(current); - if (!parsed) return current; - const match = parsed.located.find((l) => - selectorTargetsId(l.animation.targetSelector, elementId), - ); - if (!match) return current; - const updated = removeAnimationFromScript(current, match.id); - // Guard against a non-removing match (would otherwise loop forever). - if (updated === current) return current; - current = updated; - } -} - -function cascadeRemoveGsapById(doc: Document, elementId: string): void { - for (const script of Array.from(doc.querySelectorAll("script"))) { - const text = script.textContent ?? ""; - if (!text.includes("gsap") && !text.includes("ScrollTrigger")) continue; - const updated = stripGsapForId(text, elementId); - if (updated !== text) script.textContent = updated; - } -} - -export function removeElementFromHtml(html: string, elementId: string): string { - const parser = new DOMParser(); - const doc = parser.parseFromString(html, "text/html"); - doc.getElementById(elementId)?.remove(); - cascadeRemoveGsapById(doc, elementId); - return "\n" + doc.documentElement.outerHTML; -} - -export interface CompositionMetadata { - compositionId: string | null; - compositionDuration: number | null; - variables: CompositionVariable[]; -} - -export function extractCompositionMetadata(html: string): CompositionMetadata { - const parser = new DOMParser(); - const doc = parser.parseFromString(html, "text/html"); - const htmlEl = doc.documentElement; - - const compositionId = htmlEl.getAttribute("data-composition-id"); - const durationStr = htmlEl.getAttribute("data-composition-duration"); - const compositionDuration = durationStr ? parseFloat(durationStr) : null; - - const variables = parseCompositionVariables(htmlEl); - - return { - compositionId, - compositionDuration: - compositionDuration && isFinite(compositionDuration) ? compositionDuration : null, - variables, - }; -} - -function parseCompositionVariables(htmlEl: Element): CompositionVariable[] { - const variablesAttr = htmlEl.getAttribute("data-composition-variables"); - if (!variablesAttr) { - return []; - } - - try { - const parsed = JSON.parse(variablesAttr); - if (!Array.isArray(parsed)) { - return []; - } - - return parsed.filter((v): v is CompositionVariable => { - if (typeof v !== "object" || v === null) return false; - if (typeof v.id !== "string" || typeof v.label !== "string") return false; - if (!["string", "number", "color", "boolean", "enum", "font", "image"].includes(v.type)) - return false; - - switch (v.type) { - case "string": - return typeof v.default === "string"; - case "number": - return typeof v.default === "number"; - case "color": - return typeof v.default === "string"; - case "boolean": - return typeof v.default === "boolean"; - case "enum": - return typeof v.default === "string" && Array.isArray(v.options); - case "font": - // default is the font-family name string; extra metadata fields are optional - return typeof v.default === "string"; - case "image": - // default is the fallback image URL string; extra metadata fields are optional - return typeof v.default === "string"; - default: - return false; - } - }); - } catch { - return []; - } -} - -export function validateCompositionHtml(html: string): ValidationResult { - const errors: string[] = []; - const warnings: string[] = []; - - const parser = new DOMParser(); - const doc = parser.parseFromString(html, "text/html"); - const htmlEl = doc.documentElement; - - const compositionId = htmlEl.getAttribute("data-composition-id"); - if (!compositionId) { - errors.push("Missing data-composition-id attribute on element"); - } - - const durationStr = htmlEl.getAttribute("data-composition-duration"); - if (!durationStr) { - errors.push("Missing data-composition-duration attribute on element"); - } else { - const duration = parseFloat(durationStr); - if (!isFinite(duration) || duration <= 0) { - errors.push("data-composition-duration must be a positive finite number"); - } - } - - const stage = doc.getElementById("stage"); - if (!stage) { - errors.push("Missing #stage element"); - } - - if (/\son\w+\s*=/i.test(html)) { - errors.push("Inline event handlers (onclick, onload, etc.) not allowed"); - } - - if (/javascript\s*:/i.test(html)) { - errors.push("javascript: URLs not allowed"); - } - - const scripts = doc.querySelectorAll("script"); - if (scripts.length > 2) { - warnings.push("Multiple script tags detected - only GSAP CDN and main script expected"); - } - - const gsapScript = extractGsapScript(doc); - if (gsapScript) { - const gsapValidation = validateCompositionGsap(gsapScript); - errors.push(...gsapValidation.errors); - warnings.push(...gsapValidation.warnings); - } - - return { - valid: errors.length === 0, - errors, - warnings, - }; -} - -function extractGsapScript(doc: Document): string | null { - const scripts = doc.querySelectorAll("script"); - for (const script of scripts) { - const content = script.textContent || ""; - if ( - content.includes("gsap.timeline") || - content.includes(".set(") || - content.includes(".to(") - ) { - return content; - } - } - return null; -} +// ponytail: compat re-export — moved to @hyperframes/parsers +export type { ParsedHtml, CompositionMetadata } from "@hyperframes/parsers"; +export { + parseHtml, + updateElementInHtml, + addElementToHtml, + removeElementFromHtml, + validateCompositionHtml, + extractCompositionMetadata, +} from "@hyperframes/parsers"; diff --git a/packages/core/src/parsers/springEase.ts b/packages/core/src/parsers/springEase.ts index 3d4fccbb22..b7ae233b85 100644 --- a/packages/core/src/parsers/springEase.ts +++ b/packages/core/src/parsers/springEase.ts @@ -1,88 +1,2 @@ -/** - * Damped harmonic oscillator solver for GSAP CustomEase spring curves. - * - * Generates an SVG path data string compatible with `CustomEase.create(id, data)`. - * The solver supports underdamped (bouncy), critically damped, and overdamped - * spring configurations. Output is normalized to x ∈ [0,1] with y starting at 0 - * and settling to 1. - */ - -export interface SpringPreset { - name: string; - label: string; - mass: number; - stiffness: number; - damping: number; -} - -export const SPRING_PRESETS: SpringPreset[] = [ - { name: "spring-gentle", label: "Gentle", mass: 1, stiffness: 100, damping: 15 }, - { name: "spring-bouncy", label: "Bouncy", mass: 1, stiffness: 180, damping: 12 }, - { name: "spring-stiff", label: "Stiff", mass: 1, stiffness: 300, damping: 20 }, - { name: "spring-wobbly", label: "Wobbly", mass: 1, stiffness: 120, damping: 8 }, - { name: "spring-heavy", label: "Heavy", mass: 3, stiffness: 200, damping: 20 }, -]; - -/** - * Solve a damped harmonic oscillator and return a GSAP CustomEase data string. - * - * The output is an SVG path (`M0,0 L... L...`) that CustomEase.create() accepts. - * The curve is normalized so x spans [0,1] and the spring settles at y = 1. - * - * @param mass - Spring mass (> 0) - * @param stiffness - Spring stiffness constant (> 0) - * @param damping - Damping coefficient (> 0) - * @param steps - Number of sample points (default 120) - */ -export function generateSpringEaseData( - mass: number, - stiffness: number, - damping: number, - steps = 120, -): string { - const w0 = Math.sqrt(stiffness / mass); - const zeta = damping / (2 * Math.sqrt(stiffness * mass)); - - // Determine simulation duration: time until oscillation settles within threshold of 1.0. - // Underdamped: ~5 time constants. Critically/overdamped: characteristic decay time. - let settleDuration: number; - if (zeta < 1) { - settleDuration = Math.min(5 / (zeta * w0), 10); - } else { - const decayRate = zeta * w0 - w0 * Math.sqrt(zeta * zeta - 1); - settleDuration = Math.min(4 / Math.max(decayRate, 0.01), 10); - } - const simDuration = Math.max(settleDuration, 1); - - const segments: string[] = ["M0,0"]; - - for (let i = 1; i <= steps; i++) { - const t = i / steps; - const simT = t * simDuration; - let value: number; - - if (zeta < 1) { - // Underdamped — oscillates before settling - const wd = w0 * Math.sqrt(1 - zeta * zeta); - value = - 1 - - Math.exp(-zeta * w0 * simT) * - (Math.cos(wd * simT) + ((zeta * w0) / wd) * Math.sin(wd * simT)); - } else if (zeta === 1) { - // Critically damped — fastest approach without oscillation - value = 1 - (1 + w0 * simT) * Math.exp(-w0 * simT); - } else { - // Overdamped — slow exponential approach - const s1 = -w0 * (zeta - Math.sqrt(zeta * zeta - 1)); - const s2 = -w0 * (zeta + Math.sqrt(zeta * zeta - 1)); - value = 1 + (s1 * Math.exp(s2 * simT) - s2 * Math.exp(s1 * simT)) / (s2 - s1); - } - - segments.push(`${t.toFixed(4)},${value.toFixed(4)}`); - } - - // Force exact endpoint - segments[segments.length - 1] = "1,1"; - - return `${segments[0]} L${segments.slice(1).join(" ")}`; -} +/** @deprecated Import from @hyperframes/parsers/spring-ease */ +export * from "@hyperframes/parsers/spring-ease"; diff --git a/packages/core/src/utils/cssSelector.ts b/packages/core/src/utils/cssSelector.ts index df58c28c3c..e290676e7c 100644 --- a/packages/core/src/utils/cssSelector.ts +++ b/packages/core/src/utils/cssSelector.ts @@ -1,14 +1,2 @@ -// ponytail: queries DOM by exact attribute match without interpolating -// the value into a selector string — zero injection surface. -export function queryByAttr( - root: ParentNode, - attr: string, - value: string, - tag?: string, -): Element | null { - const selector = tag ? `${tag}[${attr}]` : `[${attr}]`; - for (const el of root.querySelectorAll(selector)) { - if (el.getAttribute(attr) === value) return el; - } - return null; -} +// ponytail: compat re-export — moved to @hyperframes/parsers +export { queryByAttr } from "@hyperframes/parsers"; diff --git a/packages/parsers/package.json b/packages/parsers/package.json index 3fbef63495..e09a4a27f6 100644 --- a/packages/parsers/package.json +++ b/packages/parsers/package.json @@ -42,6 +42,10 @@ "./hf-ids": { "import": "./src/hfIds.ts", "types": "./src/hfIds.ts" + }, + "./gsap-parser-recast": { + "import": "./src/gsapParser.ts", + "types": "./src/gsapParser.ts" } }, "publishConfig": { @@ -75,6 +79,10 @@ "./hf-ids": { "import": "./dist/hfIds.js", "types": "./dist/hfIds.d.ts" + }, + "./gsap-parser-recast": { + "import": "./dist/gsapParser.js", + "types": "./dist/gsapParser.d.ts" } }, "main": "./dist/index.js", @@ -87,7 +95,16 @@ "typecheck": "tsc --noEmit", "prepublishOnly": "echo skip" }, + "dependencies": { + "@babel/parser": "^7.27.0", + "acorn": "^8.17.0", + "acorn-walk": "^8.3.5", + "linkedom": "^0.18.12", + "magic-string": "^0.30.21", + "recast": "^0.23.11" + }, "devDependencies": { + "@hyperframes/core": "workspace:*", "@types/node": "^25.0.10", "tsup": "^8.0.0", "tsx": "^4.21.0", diff --git a/packages/core/src/parsers/__goldens__/complex.parsed.json b/packages/parsers/src/__goldens__/complex.parsed.json similarity index 100% rename from packages/core/src/parsers/__goldens__/complex.parsed.json rename to packages/parsers/src/__goldens__/complex.parsed.json diff --git a/packages/core/src/parsers/__goldens__/complex.serialized.js b/packages/parsers/src/__goldens__/complex.serialized.js similarity index 100% rename from packages/core/src/parsers/__goldens__/complex.serialized.js rename to packages/parsers/src/__goldens__/complex.serialized.js diff --git a/packages/core/src/parsers/__goldens__/fromto.parsed.json b/packages/parsers/src/__goldens__/fromto.parsed.json similarity index 100% rename from packages/core/src/parsers/__goldens__/fromto.parsed.json rename to packages/parsers/src/__goldens__/fromto.parsed.json diff --git a/packages/core/src/parsers/__goldens__/fromto.serialized.js b/packages/parsers/src/__goldens__/fromto.serialized.js similarity index 100% rename from packages/core/src/parsers/__goldens__/fromto.serialized.js rename to packages/parsers/src/__goldens__/fromto.serialized.js diff --git a/packages/core/src/parsers/__goldens__/minimal.parsed.json b/packages/parsers/src/__goldens__/minimal.parsed.json similarity index 100% rename from packages/core/src/parsers/__goldens__/minimal.parsed.json rename to packages/parsers/src/__goldens__/minimal.parsed.json diff --git a/packages/core/src/parsers/__goldens__/minimal.serialized.js b/packages/parsers/src/__goldens__/minimal.serialized.js similarity index 100% rename from packages/core/src/parsers/__goldens__/minimal.serialized.js rename to packages/parsers/src/__goldens__/minimal.serialized.js diff --git a/packages/core/src/parsers/__goldens__/moderate.parsed.json b/packages/parsers/src/__goldens__/moderate.parsed.json similarity index 100% rename from packages/core/src/parsers/__goldens__/moderate.parsed.json rename to packages/parsers/src/__goldens__/moderate.parsed.json diff --git a/packages/core/src/parsers/__goldens__/moderate.serialized.js b/packages/parsers/src/__goldens__/moderate.serialized.js similarity index 100% rename from packages/core/src/parsers/__goldens__/moderate.serialized.js rename to packages/parsers/src/__goldens__/moderate.serialized.js diff --git a/packages/parsers/src/gsapConstants.ts b/packages/parsers/src/gsapConstants.ts new file mode 100644 index 0000000000..8bea681c7b --- /dev/null +++ b/packages/parsers/src/gsapConstants.ts @@ -0,0 +1,124 @@ +/** + * GSAP property and ease constants. + * + * Extracted into a standalone module so browser code can import them + * without pulling in gsapParser (which depends on recast / @babel/parser). + */ + +export const SUPPORTED_PROPS = [ + // 2D Transforms + "x", + "y", + "scale", + "scaleX", + "scaleY", + "rotation", + "skewX", + "skewY", + // 3D Transforms + "z", + "rotationX", + "rotationY", + "rotationZ", + "perspective", + "transformPerspective", + "transformOrigin", + // Visibility + "opacity", + "visibility", + "autoAlpha", + // Dimensions + "width", + "height", + // Colors + "color", + "backgroundColor", + "borderColor", + // Box model + "borderRadius", + // Typography + "fontSize", + "letterSpacing", + // Filter & Clipping + "filter", + "clipPath", + // DOM content (number counters, text roll-ups) + "innerText", +]; + +// ── Property Groups ───────────────────────────────────────────────────────── +// Each group maps to an independent GSAP tween so editing one property +// (e.g. drag → x/y) never contaminates another (e.g. scale, rotation). + +export type PropertyGroupName = "position" | "scale" | "size" | "rotation" | "visual" | "other"; + +export const PROPERTY_GROUPS: Record> = { + position: new Set(["x", "y", "xPercent", "yPercent"]), + scale: new Set(["scale", "scaleX", "scaleY"]), + size: new Set(["width", "height"]), + rotation: new Set(["rotation", "skewX", "skewY"]), + visual: new Set(["opacity", "autoAlpha"]), + other: new Set(), +}; + +const PROP_TO_GROUP = new Map(); +for (const [group, props] of Object.entries(PROPERTY_GROUPS) as [ + PropertyGroupName, + ReadonlySet, +][]) { + for (const p of props) PROP_TO_GROUP.set(p, group); +} + +export function classifyPropertyGroup(prop: string): PropertyGroupName { + return PROP_TO_GROUP.get(prop) ?? "other"; +} + +export function classifyTweenPropertyGroup( + properties: Record, +): PropertyGroupName | undefined { + const groups = new Set(); + for (const key of Object.keys(properties)) { + // transformOrigin is a modifier; `_auto` is Studio's internal endpoint marker; + // `data` is GSAP-reserved (carries the Studio hold-set tag). None is an animated + // property, so none should affect the group. + if (key === "transformOrigin" || key === "_auto" || key === "data") continue; + const g = classifyPropertyGroup(key); + groups.add(g); + } + if (groups.size === 1) return groups.values().next().value; + return undefined; +} + +export const SUPPORTED_EASES = [ + "none", + "power1.in", + "power1.out", + "power1.inOut", + "power2.in", + "power2.out", + "power2.inOut", + "power3.in", + "power3.out", + "power3.inOut", + "power4.in", + "power4.out", + "power4.inOut", + "back.in", + "back.out", + "back.inOut", + "elastic.in", + "elastic.out", + "elastic.inOut", + "bounce.in", + "bounce.out", + "bounce.inOut", + "expo.in", + "expo.out", + "expo.inOut", + "spring-gentle", + "spring-bouncy", + "spring-stiff", + "spring-wobbly", + "spring-heavy", + "steps(1)", +]; diff --git a/packages/core/src/parsers/gsapInline.test.ts b/packages/parsers/src/gsapInline.test.ts similarity index 100% rename from packages/core/src/parsers/gsapInline.test.ts rename to packages/parsers/src/gsapInline.test.ts diff --git a/packages/core/src/parsers/gsapInline.ts b/packages/parsers/src/gsapInline.ts similarity index 100% rename from packages/core/src/parsers/gsapInline.ts rename to packages/parsers/src/gsapInline.ts diff --git a/packages/core/src/parsers/gsapParser.acorn.test.ts b/packages/parsers/src/gsapParser.acorn.test.ts similarity index 100% rename from packages/core/src/parsers/gsapParser.acorn.test.ts rename to packages/parsers/src/gsapParser.acorn.test.ts diff --git a/packages/core/src/parsers/gsapParser.golden.test.ts b/packages/parsers/src/gsapParser.golden.test.ts similarity index 100% rename from packages/core/src/parsers/gsapParser.golden.test.ts rename to packages/parsers/src/gsapParser.golden.test.ts diff --git a/packages/core/src/parsers/gsapParser.stress.test.ts b/packages/parsers/src/gsapParser.stress.test.ts similarity index 100% rename from packages/core/src/parsers/gsapParser.stress.test.ts rename to packages/parsers/src/gsapParser.stress.test.ts diff --git a/packages/core/src/parsers/gsapParser.test-helpers.ts b/packages/parsers/src/gsapParser.test-helpers.ts similarity index 100% rename from packages/core/src/parsers/gsapParser.test-helpers.ts rename to packages/parsers/src/gsapParser.test-helpers.ts diff --git a/packages/core/src/parsers/gsapParser.test.ts b/packages/parsers/src/gsapParser.test.ts similarity index 99% rename from packages/core/src/parsers/gsapParser.test.ts rename to packages/parsers/src/gsapParser.test.ts index 69a497da9a..fc4a763b9a 100644 --- a/packages/core/src/parsers/gsapParser.test.ts +++ b/packages/parsers/src/gsapParser.test.ts @@ -29,7 +29,7 @@ import { } from "./gsapParser.js"; import type { GsapAnimation } from "./gsapParser.js"; import { classifyPropertyGroup, classifyTweenPropertyGroup } from "./gsapConstants.js"; -import type { Keyframe } from "../core.types"; +import type { Keyframe } from "./types.js"; import { parseAndSerialize, parseSingleAnimation, diff --git a/packages/parsers/src/gsapParser.ts b/packages/parsers/src/gsapParser.ts new file mode 100644 index 0000000000..ac40a8adf0 --- /dev/null +++ b/packages/parsers/src/gsapParser.ts @@ -0,0 +1,3072 @@ +/** + * Node-only GSAP AST parser. Depends on recast / @babel/parser, which compile + * to CommonJS that calls `require("fs")` — so this module must never be in the + * static import graph of isomorphic/browser code. It is reachable only via the + * `@hyperframes/core/gsap-parser` subpath (studio-api mutations + the linter). + * + * Recast-free helpers (serialization, keyframe conversion, validation, types) + * live in `./gsapSerialize` and are re-exported here so this subpath exposes the + * full surface for tests and server-side consumers. + */ +import * as recast from "recast"; +import { parse as babelParse } from "@babel/parser"; +import { + type ArcPathConfig, + type ArcPathSegment, + type GsapAnimation, + type GsapKeyframesData, + type GsapMethod, + type GsapPercentageKeyframe, + type ParsedGsap, + serializeValue as valueToCode, + safeJsKey as safeKey, + resolveConversionProps, +} from "./gsapSerialize"; + +export type { + ArcPathConfig, + ArcPathSegment, + GsapAnimation, + GsapMethod, + ParsedGsap, + GsapKeyframesData, + GsapPercentageKeyframe, + GsapKeyframeFormat, +} from "./gsapSerialize"; +export { + serializeGsapAnimations, + getAnimationsForElementId, + validateCompositionGsap, + keyframesToGsapAnimations, + gsapAnimationsToKeyframes, + SUPPORTED_PROPS, + SUPPORTED_EASES, +} from "./gsapSerialize"; +export type { PropertyGroupName } from "./gsapConstants"; +export { + PROPERTY_GROUPS, + classifyPropertyGroup, + classifyTweenPropertyGroup, +} from "./gsapConstants"; +import { classifyPropertyGroup, classifyTweenPropertyGroup } from "./gsapConstants"; +import type { PropertyGroupName } from "./gsapConstants"; +export { generateSpringEaseData, SPRING_PRESETS } from "./springEase"; +export type { SpringPreset } from "./springEase"; + +const GSAP_METHODS = new Set(["set", "to", "from", "fromTo"]); + +// ── Recast / Babel AST shape types ──────────────────────────────────────── +// +// Recast's own typings are loose (`any` everywhere). These local shapes +// capture the properties we actually access, giving us IDE navigation and +// catch-at-write-time safety without depending on @babel/types at runtime. + +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- recast AST nodes are inherently untyped +interface AstNode extends Record { + type: string; +} +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- recast visitor paths are inherently untyped +interface AstPath extends Record { + node: AstNode; +} + +// ── Recast AST Helpers ────────────────────────────────────────────────────── + +type ScopeBindings = ReadonlyMap; + +function parseScript(script: string) { + return recast.parse(script, { + parser: { + parse(source: string) { + return babelParse(source, { sourceType: "script", plugins: [], tokens: true }); + }, + }, + }); +} + +function collectScopeBindings(ast: AstNode): ScopeBindings { + const bindings = new Map(); + recast.types.visit(ast, { + visitVariableDeclarator(path: AstPath) { + const name = path.node.id?.name; + const init = path.node.init; + if (name && init) { + const val = resolveNode(init, bindings); + if (val !== undefined) bindings.set(name, val); + } + this.traverse(path); + }, + }); + return bindings; +} + +function resolveNode( + node: AstNode | undefined, + scope: ReadonlyMap, +): 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; + } + return undefined; +} + +function extractLiteralValue(node: AstNode | undefined, scope: ScopeBindings): unknown { + return resolveNode(node, scope); +} + +// ── Element-target resolution ─────────────────────────────────────────────── +// +// Real compositions target tweens through element variables resolved from the +// DOM (`const kicker = root.querySelector(".kicker"); tl.to(kicker, …)`), arrays +// of them (`tl.to([a, b], …)`), `gsap.utils.toArray(".sel")`, and per-element +// loop variables (`items.forEach(el => tl.to(el, …))`) — not inline string +// selectors. To make those tweens editable we resolve each target back to the +// CSS selector(s) it addresses. Resolution is lexically scoped: the same +// variable name can mean different elements in different IIFEs. + +const QUERY_METHODS = new Set(["querySelector", "querySelectorAll"]); +const ITERATION_METHODS = new Set(["forEach", "map"]); +const SCOPE_NODE_TYPES = new Set([ + "Program", + "FunctionDeclaration", + "FunctionExpression", + "ArrowFunctionExpression", +]); + +/** + * If `node` is a DOM lookup call — `x.querySelector(".sel")`, + * `document.querySelectorAll(".sel")`, `document.getElementById("id")`, or + * `gsap.utils.toArray(".sel")` — return the CSS selector it resolves to. + * `getElementById("id")` maps to `#id`. Returns null for anything else. + */ +function selectorFromQueryCall(node: AstNode, 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; +} + +/** The nearest enclosing function/program node — the binding scope of `path`. */ +function enclosingScopeNode(path: AstPath): AstNode | null { + let p = path?.parentPath; + while (p) { + if (SCOPE_NODE_TYPES.has(p.node?.type)) return p.node; + p = p.parentPath; + } + return null; +} + +/** Scope nodes enclosing `path`, innermost first. */ +function scopeChainOf(path: AstPath): AstNode[] { + const chain: AstNode[] = []; + let p = path; + while (p) { + if (SCOPE_NODE_TYPES.has(p.node?.type)) chain.push(p.node); + p = p.parentPath; + } + return chain; +} + +/** Per-scope element bindings: scopeNode → (variable name → selector). */ +type TargetBindings = Map>; + +function addBinding( + bindings: TargetBindings, + scopeNode: AstNode, + 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); +} + +/** + * Build a lexically-scoped index of element variables → selector. Two passes: + * (1) direct DOM-lookup assignments (`const x = root.querySelector(...)`), then + * (2) iteration callback params (`coll.forEach(el => …)`), whose element type is + * the collection's selector — resolved against the pass-1 bindings. + */ +function collectTargetBindings(ast: AstNode, scope: ScopeBindings): TargetBindings { + const bindings: TargetBindings = new Map(); + + recast.types.visit(ast, { + visitVariableDeclarator(path: AstPath) { + const name = path.node.id?.name; + const selector = selectorFromQueryCall(path.node.init, scope); + const scopeNode = enclosingScopeNode(path); + if (name && selector !== null && scopeNode) addBinding(bindings, scopeNode, name, selector); + this.traverse(path); + }, + visitAssignmentExpression(path: AstPath) { + const left = path.node.left; + const selector = selectorFromQueryCall(path.node.right, scope); + const scopeNode = enclosingScopeNode(path); + if (left?.type === "Identifier" && selector !== null && scopeNode) { + addBinding(bindings, scopeNode, left.name, selector); + } + this.traverse(path); + }, + }); + + // Pass 2: forEach/map callback params take the collection's selector. + recast.types.visit(ast, { + visitCallExpression(path: AstPath) { + const node = path.node; + const callee = node.callee; + if ( + callee?.type === "MemberExpression" && + callee.property?.type === "Identifier" && + ITERATION_METHODS.has(callee.property.name) + ) { + const collectionSelector = resolveCollectionSelector(callee.object, path, 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); + } + } + this.traverse(path); + }, + }); + + return bindings; +} + +function isFunctionNode(node: AstNode): boolean { + return ( + node?.type === "ArrowFunctionExpression" || + node?.type === "FunctionExpression" || + node?.type === "FunctionDeclaration" + ); +} + +/** Resolve the selector a `.forEach`/`.map` is iterating over (variable or inline call). */ +function resolveCollectionSelector( + node: AstNode, + callPath: AstPath, + scope: ScopeBindings, + bindings: TargetBindings, +): string | null { + if (node?.type === "Identifier") return lookupBinding(node.name, callPath, bindings); + if (node?.type === "CallExpression") return selectorFromQueryCall(node, scope); + return null; +} + +/** Resolve a variable name to its selector using the lexical scope chain of `path`. */ +function lookupBinding(name: string, path: AstPath, bindings: TargetBindings): string | null { + for (const scopeNode of scopeChainOf(path)) { + const selector = bindings.get(scopeNode)?.get(name); + if (selector !== undefined) return selector; + } + return null; +} + +/** + * Resolve a tween's first argument to a CSS selector. Handles inline string + * literals, element variables (lexically scoped), arrays of elements (joined + * into a CSS group selector), inline DOM lookup / `toArray` calls, and indexed + * access (`items[i]`). Returns null when the target can't be resolved + * statically (e.g. an object-target duration anchor `tl.to({ _: 0 }, …)`, or a + * runtime-computed selector). + */ +function resolveTargetSelector( + node: AstNode, + path: AstPath, + 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 lookupBinding(node.name, path, bindings); + } + if (node.type === "CallExpression") { + return selectorFromQueryCall(node, scope); + } + if (node.type === "ArrayExpression") { + const parts = node.elements + .map((el: AstNode) => resolveTargetSelector(el, path, 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") { + // `items[i]` — the element type is the collection's selector. + return lookupBinding(node.object.name, path, bindings); + } + return null; +} + +function objectExpressionToRecord(node: AstNode, scope: ScopeBindings): Record { + const result: Record = {}; + if (node?.type !== "ObjectExpression") return result; + for (const prop of node.properties ?? []) { + if (prop.type !== "ObjectProperty" && prop.type !== "Property") 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 { + // Preserve unresolvable values as raw source text so they survive round-trips + result[key] = `__raw:${recast.print(prop.value).code}`; + } + } + return result; +} + +// ── Timeline Variable Detection ───────────────────────────────────────────── + +function isGsapTimelineCall(node: AstNode): 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; +} + +interface TimelineDetection { + timelineVar: string | null; + timelineCount: number; + defaults?: TimelineDefaults; +} + +function extractTimelineDefaults( + callNode: AstNode, + scope: ScopeBindings, +): TimelineDefaults | undefined { + const arg = callNode.arguments?.[0]; + if (!arg || arg.type !== "ObjectExpression") return undefined; + const defaultsProp = arg.properties?.find( + (p: AstNode) => isObjectProperty(p) && propKeyName(p) === "defaults", + ); + if (!defaultsProp?.value || defaultsProp.value.type !== "ObjectExpression") return undefined; + const record = objectExpressionToRecord(defaultsProp.value, scope); + const result: TimelineDefaults = {}; + if (typeof record.ease === "string") result.ease = record.ease; + if (typeof record.duration === "number") result.duration = record.duration; + return Object.keys(result).length > 0 ? result : undefined; +} + +function findTimelineVar(ast: AstNode, scope?: ScopeBindings): TimelineDetection { + let timelineVar: string | null = null; + let timelineCount = 0; + let defaults: TimelineDefaults | undefined; + const emptyScope: ScopeBindings = scope ?? new Map(); + recast.types.visit(ast, { + visitVariableDeclarator(path: AstPath) { + if (isGsapTimelineCall(path.node.init)) { + timelineCount += 1; + if (!timelineVar) { + timelineVar = path.node.id?.name ?? null; + defaults = extractTimelineDefaults(path.node.init, emptyScope); + } + } + this.traverse(path); + }, + visitAssignmentExpression(path: AstPath) { + if (isGsapTimelineCall(path.node.right)) { + timelineCount += 1; + if (!timelineVar) { + const left = path.node.left; + if (left?.type === "Identifier") timelineVar = left.name; + defaults = extractTimelineDefaults(path.node.right, emptyScope); + } + } + this.traverse(path); + }, + }); + return { timelineVar, timelineCount, defaults }; +} + +// ── Find All Tween Calls ──────────────────────────────────────────────────── + +interface TweenCallInfo { + path: AstPath; + node: AstNode; + method: GsapMethod; + selector: string; + varsArg: AstNode; + fromArg?: AstNode; + positionArg?: AstNode; + /** True for a base `gsap.set(...)` (off-timeline) rather than `tl.set(...)`. */ + global?: boolean; +} + +/** + * True when the member chain of `callNode.callee` is rooted at the timeline + * variable — `tl.to(...)` and every link of a chain `tl.to(...).to(...)`. + */ +function isTimelineRootedCall(callNode: AstNode, timelineVar: string): boolean { + let obj = callNode.callee?.object; + while (obj?.type === "CallExpression") { + obj = obj.callee?.object; + } + return obj?.type === "Identifier" && obj.name === timelineVar; +} + +function findAllTweenCalls( + ast: AstNode, + timelineVar: string, + scope: ScopeBindings, + targetBindings: TargetBindings, +): TweenCallInfo[] { + const results: TweenCallInfo[] = []; + recast.types.visit(ast, { + visitCallExpression(path: AstPath) { + const node = path.node; + const callee = node.callee; + // A base `gsap.set("#sel", props)` is an off-timeline static hold (no position, + // no keyframe marker). Treat it as an editable `set` animation so a static + // value (e.g. a 3D transform) round-trips and re-edits in place. Restricted to + // a STRING-LITERAL selector: variable-target `gsap.set(el, ...)` holds stay + // opaque surrounding source (editing them by selector would be ambiguous). + 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, timelineVar) || isGlobalSet) + ) { + const method = callee.property.name; + if (!GSAP_METHODS.has(method)) { + this.traverse(path); + return; + } + const args = node.arguments; + if (args.length < 2) { + this.traverse(path); + return; + } + const selectorValue = + resolveTargetSelector(args[0], path, scope, targetBindings) ?? "__unresolved__"; + + if (method === "fromTo") { + results.push({ + path, + node, + method: "fromTo", + selector: selectorValue, + fromArg: args[1], + varsArg: args[2], + positionArg: args[3], + }); + } else { + results.push({ + path, + node, + method: method as GsapMethod, + selector: selectorValue, + varsArg: args[1], + positionArg: args[2], + ...(isGlobalSet ? { global: true } : {}), + }); + } + } + this.traverse(path); + }, + }); + return results; +} + +/** Keys that are stored on dedicated GsapAnimation fields (not in properties/extras). */ +const BUILTIN_VAR_KEYS = new Set(["duration", "ease", "delay"]); + +/** Keys that are never preserved (callbacks / advanced patterns). */ +const DROPPED_VAR_KEYS = new Set(["onComplete", "onStart", "onUpdate", "onRepeat"]); + +/** Keys that belong in `extras` — non-editable GSAP config that must survive round-trips. */ +const EXTRAS_KEYS = new Set([ + "stagger", + "yoyo", + "repeat", + "repeatDelay", + "snap", + "overwrite", + "immediateRender", +]); + +/** + * Extract raw source text for a property in an ObjectExpression AST node. + * Returns the printed source of the value node, suitable for verbatim re-emission. + */ +function extractRawPropertySource(varsArgNode: AstNode, key: string): string | undefined { + const node = findPropertyNode(varsArgNode, key); + return node ? recast.print(node).code : undefined; +} + +/** Find the raw AST node for a named property inside an ObjectExpression. */ +function findPropertyNode(varsArgNode: AstNode, key: string): AstNode | 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; +} + +// ── Native GSAP Keyframes Parsing ────────────────────────────────────────── + +const PERCENTAGE_KEY_RE = /^(\d+(?:\.\d+)?)%$/; + +/** Extract a string-valued ease or easeEach from an AST property node. */ +function tryResolveStringProp(propValue: AstNode, scope: ScopeBindings): string | undefined { + const val = resolveNode(propValue, scope); + return typeof val === "string" ? val : undefined; +} + +/** + * Parse a `keyframes` property value from a tween vars AST node into a + * normalized `GsapKeyframesData` structure. Handles all three GSAP formats: + * percentage objects, object arrays, and simple (property-array) objects. + */ +// fallow-ignore-next-line complexity +function parseKeyframesNode( + node: AstNode | undefined, + scope: ScopeBindings, +): GsapKeyframesData | undefined { + if (!node) return undefined; + + // ── Object array format: keyframes: [ { x: 0, duration: 0.5 }, ... ] ── + if (node.type === "ArrayExpression") { + return parseObjectArrayKeyframes(node, scope); + } + + if (node.type !== "ObjectExpression") return undefined; + + // Distinguish percentage vs simple-array by inspecting property keys/values. + 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); + if (hasArrayValue) return parseSimpleArrayKeyframes(node, scope); + + return undefined; +} + +// fallow-ignore-next-line complexity +function parsePercentageKeyframes(node: AstNode, scope: ScopeBindings): 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]!); + const record = objectExpressionToRecord(prop.value, scope); + const properties: Record = {}; + 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 } : {}), + }; +} + +function computeKeyframesTotalDuration( + varsNode: AstNode, + scope: ScopeBindings, +): number | undefined { + const kfNode = (varsNode.properties ?? []).find( + (p: AstNode) => (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); + if (typeof r.duration === "number") total += r.duration; + } + return total > 0 ? total : undefined; +} + +// fallow-ignore-next-line complexity +function parseObjectArrayKeyframes(node: AstNode, scope: ScopeBindings): GsapKeyframesData { + const elements = node.elements ?? []; + const raw: Array<{ + properties: Record; + duration?: number; + ease?: string; + }> = []; + + for (const el of elements) { + if (!el || (el.type !== "ObjectExpression" && el.type !== "ObjectProperty")) { + // Skip non-object elements + if (el?.type !== "ObjectExpression") continue; + } + const record = objectExpressionToRecord(el, scope); + const properties: Record = {}; + 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 }); + } + + // Convert durations to percentage positions. If durations are present, use + // cumulative ratios; otherwise distribute evenly. + 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]!; + 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: AstNode, scope: ScopeBindings): GsapKeyframesData { + const arrayProps: Map = 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; + } + } + + // Zip arrays into percentage keyframes (evenly spaced). + 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 = {}; + for (const [key, values] of arrayProps) { + if (i < values.length) properties[key] = values[i]!; + } + keyframes.push({ percentage, properties }); + } + + return { + format: "simple-array", + keyframes, + ...(ease ? { ease } : {}), + ...(easeEach ? { easeEach } : {}), + }; +} + +// ── MotionPath Parsing ──────────────────────────────────────────────────── + +interface MotionPathParseResult { + arcPath: ArcPathConfig; + waypoints: Array<{ x: number; y: number }>; +} + +function parseMotionPathNode( + node: AstNode | undefined, + scope: ScopeBindings, +): MotionPathParseResult | undefined { + if (!node) return undefined; + + let pathNode: AstNode | undefined; + 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); + 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 }); + } + + if (coords.length < 2) return undefined; + + let waypoints: Array<{ x: number; y: number }>; + const segments: ArcPathSegment[] = []; + + if (isCubic && coords.length >= 4) { + // type: "cubic" — coords are [anchor, cp1, cp2, anchor, cp1, cp2, anchor, ...] + // Every 3rd coord starting from 0 is an anchor, the two between are control points. + waypoints = []; + waypoints.push(coords[0]!); + for (let i = 1; i + 2 < coords.length; i += 3) { + const cp1 = coords[i]!; + const cp2 = coords[i + 1]!; + const anchor = coords[i + 2]!; + waypoints.push(anchor); + segments.push({ curviness, cp1, cp2 }); + } + } else { + // Waypoint array with global curviness + waypoints = coords; + for (let i = 0; i < waypoints.length - 1; i++) { + segments.push({ curviness }); + } + } + + return { + arcPath: { enabled: true, autoRotate, segments }, + waypoints, + }; +} + +// fallow-ignore-next-line complexity +function tweenCallToAnimation( + call: TweenCallInfo, + scope: ScopeBindings, +): Omit { + const vars = objectExpressionToRecord(call.varsArg, scope); + const properties: Record = {}; + const extras: Record = {}; + 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); + if (!keyframesData && kfNode) hasUnresolvedKeyframes = true; + continue; + } + + if (key === "motionPath") { + const mpNode = findPropertyNode(call.varsArg, "motionPath"); + motionPathResult = parseMotionPathNode(mpNode, scope); + continue; + } + + if (key === "easeEach") { + // easeEach is only meaningful alongside keyframes — handled below. + continue; + } + + if (EXTRAS_KEYS.has(key)) { + // For extras, prefer the raw AST source so complex objects like + // `stagger: { each: 0.15, from: "start" }` survive verbatim. + const rawSource = extractRawPropertySource(call.varsArg, key); + 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; + } + } + + // Apply tween-level easeEach to keyframes data. + if (keyframesData && typeof vars.easeEach === "string") { + keyframesData.easeEach = vars.easeEach as string; + } + + // When motionPath is present, reconstruct x/y as keyframe waypoints. + if (motionPathResult) { + const { waypoints } = motionPathResult; + if (!keyframesData) { + // No explicit keyframes — create synthetic percentage keyframes from waypoints. + 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 { + // Merge waypoint positions into existing keyframes at matching percentages. + // If keyframe count matches waypoint count, assign positionally. + const kfs = keyframesData.keyframes; + if (kfs.length === waypoints.length) { + for (let i = 0; i < kfs.length; i++) { + kfs[i]!.properties.x = waypoints[i]!.x; + kfs[i]!.properties.y = waypoints[i]!.y; + } + } + } + // arcPath is attached below on the animation result. + } + + let fromProperties: Record | undefined; + if (call.method === "fromTo" && call.fromArg) { + fromProperties = {}; + const fromVars = objectExpressionToRecord(call.fromArg, scope); + for (const [key, val] of Object.entries(fromVars)) { + if (typeof val === "number" || typeof val === "string") { + fromProperties[key] = val; + } + } + } + + const hasPositionArg = !!call.positionArg; + const posVal = call.positionArg ? extractLiteralValue(call.positionArg, scope) : 0; + const position: number | string = + typeof posVal === "number" ? posVal : typeof posVal === "string" ? posVal : 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); + } + + const anim: Omit = { + targetSelector: call.selector, + method: call.method, + position, + properties, + fromProperties, + duration, + ease, + }; + if (!hasPositionArg) anim.implicitPosition = true; + let group = classifyTweenPropertyGroup(properties); + if (!group && keyframesData) { + const kfProps: Record = {}; + 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 (call.selector === "__unresolved__") anim.hasUnresolvedSelector = true; + return anim; +} + +// ── Timeline Position Resolution ────────────────────────────────────────── + +const GSAP_DEFAULT_DURATION = 0.5; + +// NOTE: Label-based positions (e.g. "myLabel+=0.5") are not yet resolved — +// they fall through to parseFloat which returns null for non-numeric strings. +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; +} + +function applyTimelineDefaults( + anims: Omit[], + 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; + } + } +} + +function resolveTimelinePositions(anims: Omit[]): void { + let cursor = 0; + let prevStart = 0; + for (const anim of anims) { + // A global `gsap.set(...)` is off-timeline — it's applied once at load, not + // sequenced on the master timeline. It carries no position arg, so the + // cursor-based fallback below would otherwise hand it the comp-end time + // (every prior tween's duration summed). Pin it to 0 (its load-time start) + // and don't let it advance the cursor/prevStart for following tweens. + if (anim.method === "set" && anim.global) { + anim.resolvedStart = 0; + continue; + } + const duration = anim.method === "set" ? 0 : (anim.duration ?? GSAP_DEFAULT_DURATION); + let start: number | null; + + if (anim.implicitPosition) { + start = cursor; + } else if (typeof anim.position === "number") { + start = anim.position; + } else if (typeof anim.position === "string") { + start = resolvePositionString(anim.position, cursor, prevStart); + } else { + start = cursor; + } + + if (start != null) { + anim.resolvedStart = Math.max(0, start); + prevStart = anim.resolvedStart; + cursor = Math.max(cursor, anim.resolvedStart + duration); + } + } +} + +function sortBySourcePosition(calls: TweenCallInfo[]): void { + calls.sort((a, b) => { + 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; + }); +} + +// ── Stable ID Generation ─────────────────────────────────────────────────── + +/** + * IDs are transient — recomputed on every parse, never persisted across sessions. + * They exist only in ephemeral request/response payloads, React component state, + * and the in-memory keyframe cache (rebuilt on every page load). No database, + * localStorage, or file stores animation IDs, so changing the ID format (e.g. + * adding a `-scale`/`-position` suffix) is safe. + */ +function assignStableIds(anims: Omit[]): GsapAnimation[] { + const counts = new Map(); + 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 }; + }); +} + +// ── Shared parse (AST + located tween calls) ──────────────────────────────── + +interface ParsedGsapAst { + ast: AstNode; + scope: ScopeBindings; + timelineVar: string; + detection: TimelineDetection; + /** Tween calls in document order, each paired with its stable animation id. */ + located: Array<{ id: string; call: TweenCallInfo; animation: GsapAnimation }>; +} + +/** + * Parse a script to its recast AST plus the located tween calls. The mutation + * functions reuse this so they can edit the exact call node in place (recast + * preserves all surrounding source — interleaved `gsap.set`, element variable + * declarations, the IIFE wrapper, comments and formatting). + */ +function parseGsapAst(script: string): ParsedGsapAst { + const ast = parseScript(script); + const scope = collectScopeBindings(ast); + const targetBindings = collectTargetBindings(ast, scope); + const detection = findTimelineVar(ast, scope); + const timelineVar = detection.timelineVar ?? "tl"; + const calls = findAllTweenCalls(ast, timelineVar, scope, targetBindings); + sortBySourcePosition(calls); + const rawAnims = calls.map((call) => tweenCallToAnimation(call, scope)); + applyTimelineDefaults(rawAnims, detection.defaults); + resolveTimelinePositions(rawAnims); + const animations = assignStableIds(rawAnims); + const located = animations.map((animation, i) => ({ + id: animation.id, + call: calls[i]!, + animation, + })); + return { ast, scope, timelineVar, detection, located }; +} + +// ── Public API ────────────────────────────────────────────────────────────── + +export function parseGsapScript(script: string): ParsedGsap { + try { + const { detection, timelineVar, located } = parseGsapAst(script); + const animations = located.map((l) => l.animation); + + const timelineMatch = script.match( + new RegExp( + `^[\\s\\S]*?(?:const|let|var)\\s+${timelineVar}\\s*=\\s*gsap\\.timeline\\s*\\([^)]*\\)\\s*;?`, + ), + ); + const preamble = + timelineMatch?.[0] ?? `const ${timelineVar} = gsap.timeline({ paused: true });`; + + 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.timelineVar === null) + result.unsupportedTimelinePattern = true; + return result; + } catch { + return { animations: [], timelineVar: "tl", preamble: "", postamble: "" }; + } +} + +// ── In-place AST mutation helpers ─────────────────────────────────────────── +// +// Edits operate directly on the located call's AST node and reprint via recast, +// which preserves every untouched statement. This is what lets us edit tweens +// in real compositions (variable targets, interleaved `gsap.set`, IIFE wrapper) +// without regenerating — and discarding — the surrounding code. + +/** + * Parse a value/expression snippet into a standalone AST expression node. + * Uses an assignment (`__hf__ = `) rather than wrapping in parens so an + * object literal parses as an expression without recast re-emitting the + * surrounding parentheses. + */ +function parseExpr(code: string): AstNode { + return parseScript(`__hf__ = ${code};`).program.body[0].expression.right; +} + +function propKeyName(prop: AstNode): string | undefined { + return prop?.key?.name ?? prop?.key?.value; +} + +function isObjectProperty(prop: AstNode): boolean { + return prop?.type === "ObjectProperty" || prop?.type === "Property"; +} + +/** A key the inspector treats as an editable transform/style property. */ +function isEditablePropertyKey(key: string): boolean { + return !BUILTIN_VAR_KEYS.has(key) && !DROPPED_VAR_KEYS.has(key) && !EXTRAS_KEYS.has(key); +} + +function makeObjectProperty(key: string, value: number | string): AstNode { + const obj = parseExpr(`{ ${safeKey(key)}: ${valueToCode(value)} }`); + return obj.properties[0]; +} + +/** Set (or insert) a single key on an ObjectExpression, preserving sibling keys. */ +function setVarsKey(varsArg: AstNode, key: string, value: number | string): void { + if (varsArg?.type !== "ObjectExpression") return; + const existing = varsArg.properties.find( + (p: AstNode) => isObjectProperty(p) && propKeyName(p) === key, + ); + if (existing) { + existing.value = parseExpr(valueToCode(value)); + } else { + varsArg.properties.push(makeObjectProperty(key, value)); + } +} + +/** + * Filter an ObjectExpression's properties, keeping non-editable keys + * and delegating the keep/drop decision for editable keys to `shouldKeep`. + */ +function filterEditableKeys(varsArg: AstNode, shouldKeep: (key: string) => boolean): void { + if (varsArg?.type !== "ObjectExpression") return; + varsArg.properties = varsArg.properties.filter((p: AstNode) => { + if (!isObjectProperty(p)) return true; + const key = propKeyName(p); + if (typeof key !== "string") return true; + if (!isEditablePropertyKey(key)) return true; + return shouldKeep(key); + }); +} + +/** + * Replace the editable-property keys on an ObjectExpression with `newProps`, + * leaving `duration`, `ease`, `stagger`, callbacks and other non-editable keys + * untouched. + */ +function reconcileEditableProperties( + varsArg: AstNode, + newProps: Record, +): void { + filterEditableKeys(varsArg, (key) => key in newProps); + // Upsert each new prop, preserving the order keys first appeared. + for (const [key, value] of Object.entries(newProps)) { + setVarsKey(varsArg, key, value); + } +} + +function applyEaseUpdate(varsArg: AstNode, ease: string): void { + const kfNode = findKeyframesObjectNode(varsArg); + if (kfNode) { + setVarsKey(kfNode, "easeEach", ease); + removeVarsKey(varsArg, "ease"); + } else { + setVarsKey(varsArg, "ease", ease); + } +} + +/** + * "Apply to all segments": drop every per-keyframe `ease` override so the single + * `easeEach` governs all segments uniformly (AE select-all + F9). Mirrors the + * acorn writer's resetKeyframeEases branch. + */ +function stripKeyframeEases(varsArg: AstNode): void { + const kfNode = findKeyframesObjectNode(varsArg); + const props = kfNode?.properties; + if (!Array.isArray(props)) return; + for (const entry of props) { + if (isObjectProperty(entry)) removeVarsKey(entry.value, "ease"); + } +} + +function applyUpdatesToCall( + call: TweenCallInfo, + updates: Partial & { easeEach?: string; resetKeyframeEases?: boolean }, +): void { + if (updates.properties) reconcileEditableProperties(call.varsArg, updates.properties); + if (updates.fromProperties && call.method === "fromTo" && call.fromArg) { + reconcileEditableProperties(call.fromArg, updates.fromProperties); + } + if (updates.duration !== undefined) setVarsKey(call.varsArg, "duration", updates.duration); + if (updates.easeEach !== undefined) applyEaseUpdate(call.varsArg, updates.easeEach); + else if (updates.ease !== undefined) applyEaseUpdate(call.varsArg, updates.ease); + if (updates.resetKeyframeEases) stripKeyframeEases(call.varsArg); + if (updates.position !== undefined) { + const posIdx = call.method === "fromTo" ? 3 : 2; + call.node.arguments[posIdx] = parseExpr(valueToCode(updates.position)); + } +} + +/** Walk up to the enclosing ExpressionStatement path (for prune / insertAfter). */ +function findStatementPath(path: AstPath): AstPath | null { + let p = path; + while (p) { + if (p.node?.type === "ExpressionStatement") return p; + p = p.parentPath; + } + return null; +} + +function insertAfterAnchor(parsed: ParsedGsapAst, newStatement: AstNode): void { + const lastCall = parsed.located[parsed.located.length - 1]?.call; + const anchorPath = lastCall + ? findStatementPath(lastCall.path) + : findTimelineDeclarationPath(parsed.ast, parsed.timelineVar); + if (anchorPath) { + anchorPath.insertAfter(newStatement); + } else { + parsed.ast.program.body.push(newStatement); + } +} + +/** Build the source for a single `tl.method(selector, vars, position)` call. */ +function buildTweenStatementCode(timelineVar: string, anim: Omit): string { + const selector = JSON.stringify(anim.targetSelector); + const props: Record = { ...anim.properties }; + if (anim.method !== "set" && anim.duration !== undefined) props.duration = anim.duration; + if (anim.ease) props.ease = anim.ease; + const entries = Object.entries(props).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`); + // immediateRender forces GSAP to apply the set when added to the timeline, + // not on the first seek — without it, tl.set at position 0 on a paused + // timeline is invisible until the playhead moves past 0. A base `gsap.set` + // already runs immediately, so it doesn't need (or get) the flag. + if (anim.method === "set" && !anim.global) entries.push("immediateRender: true"); + if (anim.extras) { + for (const [k, v] of Object.entries(anim.extras)) { + entries.push(`${safeKey(k)}: ${valueToCode(v as number | string)}`); + } + } + const objCode = `{ ${entries.join(", ")} }`; + const posCode = valueToCode( + typeof anim.position === "number" ? anim.position : (anim.position ?? 0), + ); + if (anim.method === "fromTo") { + const fromEntries = Object.entries(anim.fromProperties ?? {}).map( + ([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`, + ); + const fromCode = `{ ${fromEntries.join(", ")} }`; + return `${timelineVar}.fromTo(${selector}, ${fromCode}, ${objCode}, ${posCode});`; + } + // A base `gsap.set` is off the timeline: no timeline var, no position arg. + if (anim.method === "set" && anim.global) { + return `gsap.set(${selector}, ${objCode});`; + } + return `${timelineVar}.${anim.method}(${selector}, ${objCode}, ${posCode});`; +} + +export function updateAnimationInScript( + script: string, + animationId: string, + updates: Partial & { easeEach?: string; resetKeyframeEases?: boolean }, +): string { + let parsed: ParsedGsapAst; + try { + parsed = parseGsapAst(script); + } catch (e) { + console.warn("[gsap-parser] updateAnimationInScript parse failed:", e); + return script; + } + const target = parsed.located.find((l) => l.id === animationId); + if (!target) return script; + applyUpdatesToCall(target.call, updates); + return recast.print(parsed.ast).code; +} + +export function shiftPositionsInScript( + script: string, + targetSelector: string, + delta: number, +): string { + let parsed: ParsedGsapAst; + try { + parsed = parseGsapAst(script); + } catch (e) { + console.warn("[gsap-parser] shiftPositionsInScript parse failed:", e); + return script; + } + let changed = false; + for (const entry of parsed.located) { + if (entry.animation.targetSelector !== targetSelector) continue; + if (typeof entry.animation.position !== "number") continue; + const newPos = Math.max(0, Math.round((entry.animation.position + delta) * 1000) / 1000); + applyUpdatesToCall(entry.call, { position: newPos }); + changed = true; + } + return changed ? recast.print(parsed.ast).code : script; +} + +export function scalePositionsInScript( + script: string, + targetSelector: string, + oldStart: number, + oldDuration: number, + newStart: number, + newDuration: number, +): string { + if (oldDuration <= 0 || newDuration <= 0) return script; + const ratio = newDuration / oldDuration; + let parsed: ParsedGsapAst; + try { + parsed = parseGsapAst(script); + } catch (e) { + console.warn("[gsap-parser] scalePositionsInScript parse failed:", e); + return script; + } + let changed = false; + for (const entry of parsed.located) { + if (entry.animation.targetSelector !== targetSelector) continue; + if (typeof entry.animation.position !== "number") continue; + const newPos = Math.max( + 0, + Math.round((newStart + (entry.animation.position - oldStart) * ratio) * 1000) / 1000, + ); + const updates: Partial = { position: newPos }; + if (typeof entry.animation.duration === "number" && entry.animation.duration > 0) { + updates.duration = Math.max( + 0.001, + Math.round(entry.animation.duration * ratio * 1000) / 1000, + ); + } + applyUpdatesToCall(entry.call, updates); + changed = true; + } + return changed ? recast.print(parsed.ast).code : script; +} + +function updateAnimationSelector(script: string, animationId: string, newSelector: string): string { + let parsed: ParsedGsapAst; + try { + parsed = parseGsapAst(script); + } catch { + return script; + } + const target = parsed.located.find((l) => l.id === animationId); + if (!target) return script; + const selectorArg = target.call.path.node.arguments?.[0]; + if (selectorArg?.type === "StringLiteral") { + selectorArg.value = newSelector; + } else if (selectorArg?.type === "Identifier") { + target.call.path.node.arguments[0] = { type: "StringLiteral", value: newSelector }; + } + return recast.print(parsed.ast).code; +} + +export function addAnimationToScript( + script: string, + animation: Omit, +): { script: string; id: string } { + let parsed: ParsedGsapAst; + try { + parsed = parseGsapAst(script); + } catch (e) { + console.warn("[gsap-parser] addAnimationToScript parse failed:", e); + return { script, id: "" }; + } + // Nothing to anchor against and no timeline to target — treat as parse failure. + if (parsed.located.length === 0 && parsed.detection.timelineVar === null) { + return { script, id: "" }; + } + + const id = `anim-${Date.now()}`; + const statementCode = buildTweenStatementCode(parsed.timelineVar, animation); + const newStatement = parseScript(statementCode).program.body[0]; + insertAfterAnchor(parsed, newStatement); + return { script: recast.print(parsed.ast).code, id }; +} + +export function addAnimationWithKeyframesToScript( + script: string, + targetSelector: string, + position: number, + duration: number, + keyframes: Array<{ + percentage: number; + properties: Record; + ease?: string; + auto?: boolean; + }>, + ease?: string, + easeEach?: string, +): { script: string; id: string } { + let parsed: ParsedGsapAst; + try { + parsed = parseGsapAst(script); + } catch (e) { + console.warn("[gsap-parser] addAnimationWithKeyframesToScript parse failed:", e); + return { script, id: "" }; + } + if (parsed.located.length === 0 && parsed.detection.timelineVar === null) { + return { script, id: "" }; + } + + const selector = JSON.stringify(targetSelector); + const kfCode = buildKeyframeObjectCode(keyframes, easeEach ? { easeEach } : undefined); + const varEntries = [`keyframes: ${kfCode}`, `duration: ${valueToCode(duration)}`]; + if (ease) varEntries.push(`ease: ${JSON.stringify(ease)}`); + const posCode = valueToCode(position); + const stmtCode = `${parsed.timelineVar}.to(${selector}, { ${varEntries.join(", ")} }, ${posCode});`; + + const newStatement = parseScript(stmtCode).program.body[0]; + insertAfterAnchor(parsed, newStatement); + + const result = recast.print(parsed.ast).code; + const reParsed = parseGsapAst(result); + const newId = reParsed.located[reParsed.located.length - 1]?.id ?? ""; + return { script: result, id: newId }; +} + +/** Find the statement path of `const = gsap.timeline(...)`. */ +function findTimelineDeclarationPath(ast: AstNode, timelineVar: string): AstPath | null { + let found: AstPath | null = null; + recast.types.visit(ast, { + visitVariableDeclaration(path: AstPath) { + if (found) return false; + for (const decl of path.node.declarations ?? []) { + if (decl.id?.name === timelineVar && isGsapTimelineCall(decl.init)) { + found = path; + return false; + } + } + this.traverse(path); + }, + }); + return found; +} + +/** Find the call that chains off `targetNode` (i.e. whose callee object IS it). */ +function findChainParentCall(stmtNode: AstNode, targetNode: AstNode): AstNode | null { + let found: AstNode | null = null; + recast.types.visit(stmtNode, { + visitCallExpression(p: AstPath) { + if (found) return false; + if (p.node.callee?.type === "MemberExpression" && p.node.callee.object === targetNode) { + found = p.node; + return false; + } + this.traverse(p); + }, + }); + return found; +} + +export function removeAnimationFromScript(script: string, animationId: string): string { + let parsed: ParsedGsapAst; + try { + parsed = parseGsapAst(script); + } catch (e) { + console.warn("[gsap-parser] removeAnimationFromScript parse failed:", e); + return script; + } + let target = parsed.located.find((l) => l.id === animationId); + if (!target) { + const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-"); + target = parsed.located.find((l) => l.id === convertedId); + } + if (!target) return script; + const node = target.call.node; + const stmtPath = findStatementPath(target.call.path); + if (!stmtPath) return script; + + const parentCall = findChainParentCall(stmtPath.node, node); + if (parentCall) { + // Inner link of a chain — splice it out by re-pointing the next link. + parentCall.callee.object = node.callee.object; + } else if (node.callee?.object?.type === "CallExpression") { + // Outermost link of a chain with earlier links — drop just this link. + stmtPath.node.expression = node.callee.object; + } else { + // Standalone tween — remove the whole statement. + stmtPath.prune(); + } + return recast.print(parsed.ast).code; +} + +function insertInheritedStateSet( + script: string, + selector: string, + position: number, + properties: Record, +): string { + let parsed: ParsedGsapAst; + try { + parsed = parseGsapAst(script); + } catch { + return script; + } + const tlVar = parsed.timelineVar; + const props = Object.entries(properties) + .map(([k, v]) => `${k}: ${typeof v === "string" ? JSON.stringify(v) : v}`) + .join(", "); + const code = `${tlVar}.set(${JSON.stringify(selector)}, { ${props} }, ${position});`; + const newStatement = parseScript(code).program.body[0]; + const anchor = findTimelineDeclarationPath(parsed.ast, tlVar); + if (anchor) { + anchor.insertAfter(newStatement); + } else if (parsed.located.length > 0) { + const firstTween = parsed.located[0]!.call; + const stmtPath = findStatementPath(firstTween.path); + if (stmtPath) stmtPath.insertBefore(newStatement); + else parsed.ast.program.body.unshift(newStatement); + } else { + parsed.ast.program.body.push(newStatement); + } + return recast.print(parsed.ast).code; +} + +/** Marker on Studio-emitted pre-keyframe hold `set`s. `data` is a GSAP-reserved + * config key (attached to the tween, never applied to the target), so it carries + * the tag without triggering GSAP's "Invalid property" warning. */ +const STUDIO_HOLD_MARKER = "hf-hold"; + +/** True for a `tl.set(...)` this module emitted to hold a keyframe before its tween. + * The Studio filters these out so they never appear as user keyframes/diamonds. */ +export function isStudioHoldSet(anim: GsapAnimation): boolean { + return anim.method === "set" && anim.properties?.data === STUDIO_HOLD_MARKER; +} + +/** + * Keep a `tl.set(selector, {x,y}, 0)` "hold" in front of every position-keyframed + * tween that starts after t=0, so the element holds its first keyframe's position + * BEFORE the tween plays instead of snapping to its CSS base (the universal NLE + * "hold before first keyframe" behavior). The set is tagged with `data: "hf-hold"` + * so this pass owns it: every call wipes the prior holds and recomputes from the + * current keyframes, keeping them in sync as keyframes are added/moved/deleted. + * + * Idempotent. Only position props (x/y/xPercent/yPercent) are held — opacity/scale + * keep their authored pre-tween behavior. A tween already starting at 0 needs no + * hold (no gap before it). + */ +export function syncPositionHoldsBeforeKeyframes(script: string): string { + let parsed: ParsedGsap; + try { + parsed = parseGsapScript(script); + } catch { + return script; + } + // 1. Drop every hold this pass previously emitted, so we recompute fresh. + let result = script; + const staleHoldIds = parsed.animations.filter(isStudioHoldSet).map((a) => a.id); + for (const id of staleHoldIds) result = removeAnimationFromScript(result, id); + + // 2. Re-add a hold for each position-keyframed tween that starts after t=0. + let reparsed: ParsedGsap; + try { + reparsed = parseGsapScript(result); + } catch { + return result; + } + for (const anim of reparsed.animations) { + if (!anim.keyframes) continue; + const start = anim.resolvedStart ?? (typeof anim.position === "number" ? anim.position : 0); + if (!(start > 0.001)) continue; + const firstKf = [...anim.keyframes.keyframes].sort((a, b) => a.percentage - b.percentage)[0]; + if (!firstKf) continue; + const posProps: Record = {}; + for (const [k, v] of Object.entries(firstKf.properties)) { + if (classifyPropertyGroup(k) === "position" && typeof v === "number") posProps[k] = v; + } + if (Object.keys(posProps).length === 0) continue; + result = insertInheritedStateSet(result, anim.targetSelector, 0, { + ...posProps, + data: STUDIO_HOLD_MARKER, + }); + } + return result; +} + +// ── Split Animation Functions ───────────────────────────────────────────── + +export interface SplitAnimationsOptions { + originalId: string; + newId: string; + splitTime: number; + elementStart: number; + elementDuration: number; +} + +export interface SplitAnimationsResult { + script: string; + /** Non-ID-selector animations that the engine cannot safely retarget. */ + skippedSelectors: string[]; +} + +// fallow-ignore-next-line complexity +export function splitAnimationsInScript( + script: string, + opts: SplitAnimationsOptions, +): SplitAnimationsResult { + const parsed = parseGsapScript(script); + const originalSelector = `#${opts.originalId}`; + const newSelector = `#${opts.newId}`; + + const skippedSelectors: string[] = []; + for (const a of parsed.animations) { + if (a.targetSelector !== originalSelector && a.targetSelector.includes(opts.originalId)) { + skippedSelectors.push(a.targetSelector); + } + } + + const matching = parsed.animations.filter((a) => a.targetSelector === originalSelector); + if (matching.length === 0) return { script, skippedSelectors }; + + let result = script; + const newElementStart = opts.splitTime; + const inheritedProps: Record = {}; + + // Reverse iteration: updateAnimationSelector mutates selectors in the source + // string, which can shift count-based ID suffixes (e.g. "#hero-1" → "#hero-2") + // for later animations. Processing last-to-first prevents stale ID collisions. + for (let i = matching.length - 1; i >= 0; i--) { + const anim = matching[i]!; + const pos = typeof anim.position === "number" ? anim.position : 0; + const dur = anim.duration ?? 0; + const animEnd = pos + dur; + + if (anim.keyframes) { + if (pos >= opts.splitTime) { + result = updateAnimationSelector(result, anim.id, newSelector); + } else if (animEnd > opts.splitTime) { + // Spanning keyframes can't be correctly split without renormalizing + // percentages and durations — leave on original, warn the caller. + skippedSelectors.push(`${originalSelector} (keyframes spanning split)`); + const kfs = anim.keyframes.keyframes; + for (const kf of kfs) { + const kfTime = pos + (kf.percentage / 100) * dur; + if (kfTime <= opts.splitTime) { + for (const [k, v] of Object.entries(kf.properties)) { + inheritedProps[k] = v; + } + } + } + } else { + // Entirely before split — extract final keyframe properties + const kfs = anim.keyframes.keyframes; + if (kfs.length > 0) { + for (const [k, v] of Object.entries(kfs[kfs.length - 1]!.properties)) { + inheritedProps[k] = v; + } + } + } + continue; + } + + // `<=` (not `<`) is deliberate: a tween whose end coincides exactly with + // the split boundary has fully played by splitTime, so it belongs to the + // first half and contributes its resting state to the clone. The spanning + // branch below handles only strictly-mid-flight tweens (pos < split < end). + if (animEnd <= opts.splitTime) { + // Only a completed .from() reverts the element to its natural state, so + // its recorded properties are the HIDDEN start (e.g. opacity:0), not the + // resting state — clearing them keeps the clone at its natural value + // instead of pinning it to the from-values (which made it invisible). + // .fromTo() and .to() both END at their to-values (no revert), so they + // fall through to `else` and inherit `anim.properties` (the to-values) — + // .fromTo() must NOT join the .from() clear-branch or the clone would + // drop the very state the fromTo just established. + if (anim.method === "from") { + for (const k of Object.keys(anim.properties)) delete inheritedProps[k]; + } else { + for (const [k, v] of Object.entries(anim.properties)) { + inheritedProps[k] = v; + } + } + continue; + } + + if (pos >= opts.splitTime) { + result = updateAnimationSelector(result, anim.id, newSelector); + continue; + } + + // Spans the split — use linear interpolation to compute mid-values, + // then .fromTo() on the clone so both halves play the correct range. + // For .fromTo() tweens we have explicit from-values; for .to() tweens + // we use accumulated state from prior animations, defaulting to 0 for + // unknown numeric properties (the standard GSAP transform initial state). + const progress = dur > 0 ? (opts.splitTime - pos) / dur : 0; + const fromSource = anim.fromProperties ?? inheritedProps; + const midProps: Record = {}; + for (const [k, v] of Object.entries(anim.properties)) { + if (typeof v !== "number") { + midProps[k] = v; + continue; + } + const fromVal = typeof fromSource[k] === "number" ? (fromSource[k] as number) : 0; + midProps[k] = fromVal + (v - fromVal) * progress; + } + + const firstHalfDuration = opts.splitTime - pos; + result = updateAnimationInScript(result, anim.id, { + duration: firstHalfDuration, + properties: midProps, + }); + + const secondHalfDuration = animEnd - opts.splitTime; + const addResult = addAnimationToScript(result, { + targetSelector: newSelector, + method: "fromTo", + position: newElementStart, + duration: secondHalfDuration, + properties: { ...anim.properties }, + fromProperties: { ...midProps }, + ease: anim.ease, + extras: anim.extras, + }); + result = addResult.script; + + for (const [k, v] of Object.entries(midProps)) { + inheritedProps[k] = v; + } + } + + if (Object.keys(inheritedProps).length > 0) { + result = insertInheritedStateSet(result, newSelector, newElementStart, inheritedProps); + } + + return { script: result, skippedSelectors }; +} + +// ── Keyframe Mutation Functions ──────────────────────────────────────────── + +function sortedKeyframes( + kfs: Array<{ percentage: number; properties: Record; ease?: string }>, +) { + return kfs.slice().sort((a, b) => a.percentage - b.percentage); +} + +function keyframePropsToCode(kf: { properties: Record }): string[] { + return Object.entries(kf.properties).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`); +} + +function buildKeyframeObjectCode( + keyframes: Array<{ + percentage: number; + properties: Record; + ease?: string; + auto?: boolean; + }>, + options?: { easeEach?: string }, +): string { + const entries = keyframes.map((kf) => { + const props = keyframePropsToCode(kf); + if (kf.ease) props.push(`ease: ${JSON.stringify(kf.ease)}`); + if (kf.auto) props.push(`_auto: 1`); + return `${JSON.stringify(`${kf.percentage}%`)}: { ${props.join(", ")} }`; + }); + if (options?.easeEach) entries.push(`easeEach: ${JSON.stringify(options.easeEach)}`); + return `{ ${entries.join(", ")} }`; +} + +/** Remove a named property from an ObjectExpression's properties array. */ +function removeVarsKey(varsArg: AstNode, key: string): void { + if (varsArg?.type !== "ObjectExpression") return; + varsArg.properties = varsArg.properties.filter( + (p: AstNode) => !(isObjectProperty(p) && propKeyName(p) === key), + ); +} + +/** Extract the numeric percentage from a key like "50%". Returns NaN for non-percentage keys. */ +function percentageFromKey(key: string): number { + const m = PERCENTAGE_KEY_RE.exec(key); + return m ? Number.parseFloat(m[1]!) : Number.NaN; +} + +const PCT_TOLERANCE = 2; + +function findKeyframePropByPct( + kfNode: AstNode, + percentage: number, +): { idx: number; prop: AstNode } | null { + const props = kfNode.properties; + for (let i = 0; i < props.length; i++) { + if (!isObjectProperty(props[i])) continue; + const key = propKeyName(props[i]); + if (typeof key !== "string") continue; + const parsed = percentageFromKey(key); + if (Number.isNaN(parsed)) continue; + if (Math.abs(parsed - percentage) <= PCT_TOLERANCE) return { idx: i, prop: props[i] }; + } + return null; +} + +/** Build a keyframe value AST node from properties and optional ease. */ +function buildKeyframeValueNode( + properties: Record, + ease?: string, +): AstNode { + const entries = Object.entries(properties).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`); + if (ease) entries.push(`ease: ${JSON.stringify(ease)}`); + return parseExpr(`{ ${entries.join(", ")} }`); +} + +/** Parse + locate a target animation, returning null on failure. */ +function locateAnimation( + script: string, + animationId: string, +): { parsed: ParsedGsapAst; target: ParsedGsapAst["located"][number] } | null { + let parsed: ParsedGsapAst; + try { + parsed = parseGsapAst(script); + } catch { + return null; + } + const target = parsed.located.find((l) => l.id === animationId); + return target ? { parsed, target } : null; +} + +// Animation ids encode the tween's timeline position in ms +// (`#puck-a-to-1200-position`). A gesture/convert can re-emit a tween at a +// different position, changing its id — so a client that cached the old id (its +// selectedGsapAnimations hasn't refreshed) edits a now-nonexistent id and the op +// no-ops. Parse `{selector}-{method}-{posMs}-{group}` so we can fall back to the +// same selector+method+group tween nearest the requested position. +const ANIM_ID_RE = /^(.*)-(fromTo|from|to|set)-(\d+)-([a-z]+)$/; + +function locateAnimationWithFallback( + script: string, + animationId: string, +): ReturnType { + const loc = locateAnimation(script, animationId); + if (loc) return loc; + const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-"); + if (convertedId !== animationId) { + const converted = locateAnimation(script, convertedId); + if (converted) return converted; + } + // Position-drift fallback: match by stable identity (selector+method+group), + // disambiguating by the position closest to the one the caller asked for. + const want = ANIM_ID_RE.exec(animationId); + if (!want) return null; + const [, sel, method, wantPosStr, group] = want; + const wantPos = Number(wantPosStr); + let parsed: ParsedGsapAst; + try { + parsed = parseGsapAst(script); + } catch { + return null; + } + let best: ParsedGsapAst["located"][number] | null = null; + let bestDist = Number.POSITIVE_INFINITY; + for (const l of parsed.located) { + const m = ANIM_ID_RE.exec(l.id); + if (!m || m[1] !== sel || m[2] !== method || m[4] !== group) continue; + const dist = Math.abs(Number(m[3]) - wantPos); + if (dist < bestDist) { + best = l; + bestDist = dist; + } + } + return best ? { parsed, target: best } : null; +} + +/** Find the keyframes ObjectExpression node on a tween's varsArg, or null. */ +function findKeyframesObjectNode(varsArg: AstNode): AstNode | null { + const node = findPropertyNode(varsArg, "keyframes"); + return node?.type === "ObjectExpression" ? node : null; +} + +/** + * Convert array-form keyframes (`keyframes: [{x,y}, …]`) to even-percentage object + * form (`{ "0%": {…}, "33.3%": {…}, … }`) IN PLACE, returning the new object node + * (or null if not array-form). GSAP distributes an array evenly, so this is + * runtime-identical — but it gives the percentage-keyed write ops something to + * target. Needed before INSERTING a keyframe at an arbitrary percentage, which an + * even array can't host. + */ +function convertArrayKeyframesToObjectNode(varsArg: AstNode): AstNode | null { + if (varsArg?.type !== "ObjectExpression") return null; + const prop = (varsArg.properties ?? []).find( + (p: AstNode) => isObjectProperty(p) && propKeyName(p) === "keyframes", + ); + if (!prop || prop.value?.type !== "ArrayExpression") return null; + const els: AstNode[] = (prop.value.elements ?? []).filter( + (e: AstNode | null): e is AstNode => !!e && e.type === "ObjectExpression", + ); + const n = els.length; + if (n === 0) return null; + const entries = els.map((el: AstNode, i: number) => { + const pct = n > 1 ? Math.round((i / (n - 1)) * 1000) / 10 : 0; + return `${JSON.stringify(`${pct}%`)}: ${recast.print(el).code}`; + }); + prop.value = parseExpr(`{ ${entries.join(", ")} }`); + return prop.value; +} + +/** Filter percentage-keyed properties from a keyframes ObjectExpression. */ +function filterPercentageProps(kfNode: AstNode): AstNode[] { + return kfNode.properties.filter((p: AstNode) => { + if (!isObjectProperty(p)) return false; + const key = propKeyName(p); + return typeof key === "string" && PERCENTAGE_KEY_RE.test(key); + }); +} + +/** + * Collapse a keyframes node to flat tween: apply `record` entries as vars keys, + * then remove `keyframes` and `easeEach` from varsArg. Skips the `ease` key + * from the record (per-keyframe ease, not a tween ease). + */ +function collapseKeyframesToFlat(varsArg: AstNode, record: Record): void { + for (const [k, v] of Object.entries(record)) { + if (k === "ease") continue; + if (typeof v === "number" || typeof v === "string") setVarsKey(varsArg, k, v); + } + removeVarsKey(varsArg, "keyframes"); + removeVarsKey(varsArg, "easeEach"); +} + +/** + * Locate an animation's keyframes ObjectExpression and build the percentage key. + * Shared preamble for addKeyframeToScript, removeKeyframeFromScript, and + * updateKeyframeInScript. + */ +function locateKeyframeCtx(script: string, animationId: string, percentage: number) { + const loc = locateAnimationWithFallback(script, animationId); + if (!loc) return null; + const kfNode = findKeyframesObjectNode(loc.target.call.varsArg); + if (!kfNode) return null; + return { loc, kfNode, pctKey: `${percentage}%` }; +} + +/** + * Insert a keyframe at the given percentage in an existing percentage-keyframes + * object. If the percentage already exists, its value is replaced. + */ +export function addKeyframeToScript( + script: string, + animationId: string, + percentage: number, + properties: Record, + ease?: string, + backfillDefaults?: Record, +): string { + let loc = locateAnimationWithFallback(script, animationId); + if (!loc) return script; + let kfNode = findKeyframesObjectNode(loc.target.call.varsArg); + + // Array-form keyframes can't host an arbitrary new percentage — normalize to + // object form in place first. (convertToKeyframesInScript below only converts + // FLAT tweens; it early-returns when keyframes already exist.) + if (!kfNode) kfNode = convertArrayKeyframesToObjectNode(loc.target.call.varsArg); + + if (!kfNode) { + script = convertToKeyframesInScript(script, animationId); + loc = locateAnimationWithFallback(script, animationId); + if (!loc) return script; + kfNode = findKeyframesObjectNode(loc.target.call.varsArg); + if (!kfNode) return script; + } + const pctKey = `${percentage}%`; + + const newValueNode = buildKeyframeValueNode(properties, ease); + + // Merge into existing keyframe at this percentage, or insert new + const existing = findKeyframePropByPct(kfNode, percentage); + if (existing) { + if (existing.prop.value?.type === "ObjectExpression") { + const existingRecord = objectExpressionToRecord(existing.prop.value, loc.parsed.scope); + const merged = { ...existingRecord }; + for (const [k, v] of Object.entries(properties)) merged[k] = v; + existing.prop.value = buildKeyframeValueNode( + merged as Record, + ease ?? (typeof existingRecord.ease === "string" ? existingRecord.ease : undefined), + ); + } else { + existing.prop.value = newValueNode; + } + } else { + // Build the new property node with a quoted percentage key + const newProp = parseExpr(`{ ${JSON.stringify(pctKey)}: {} }`).properties[0]; + newProp.value = newValueNode; + + // Insert in sorted order by percentage + let insertIdx = kfNode.properties.length; + for (let i = 0; i < kfNode.properties.length; i++) { + const key = isObjectProperty(kfNode.properties[i]) + ? propKeyName(kfNode.properties[i]) + : undefined; + if (typeof key === "string" && percentageFromKey(key) > percentage) { + insertIdx = i; + break; + } + } + kfNode.properties.splice(insertIdx, 0, newProp); + } + + // Auto-update adjacent endpoints: only update an `_auto` 0% or 100% + // keyframe when the new keyframe is directly next to it (no other keyframe + // between them). This prevents a keyframe at 74% from clobbering 100% when + // 75% already exists, and a keyframe at 30% from clobbering 0% when 25% + // already exists. + if (percentage > 0 && percentage < 100) { + const pctProps = filterPercentageProps(kfNode); + const allPcts = pctProps + .map((p: AstNode) => percentageFromKey(propKeyName(p) ?? "")) + .filter((n: number) => !Number.isNaN(n) && n !== percentage) + .sort((a: number, b: number) => a - b); + const leftNeighbor = allPcts.filter((p: number) => p < percentage).pop(); + const rightNeighbor = allPcts.find((p: number) => p > percentage); + for (const endPct of [0, 100]) { + const isNeighbor = endPct === 0 ? leftNeighbor === 0 : rightNeighbor === 100; + if (!isNeighbor) continue; + const endProp = pctProps.find( + (p: AstNode) => percentageFromKey(propKeyName(p) ?? "") === endPct, + ); + if (!endProp?.value || endProp.value.type !== "ObjectExpression") continue; + const hasAuto = endProp.value.properties.some( + (p: AstNode) => isObjectProperty(p) && propKeyName(p) === "_auto", + ); + if (!hasAuto) continue; + const updatedProps = { ...properties, _auto: 1 as number | string }; + endProp.value = buildKeyframeValueNode(updatedProps, undefined); + } + } + + // Backfill: when the new keyframe introduces properties absent from other + // keyframes, add default values so GSAP can interpolate them. + if (backfillDefaults) { + const newPropKeys = Object.keys(properties); + const pctProps = filterPercentageProps(kfNode); + for (const prop of pctProps) { + const key = propKeyName(prop); + if (key === pctKey) continue; + const valObj = prop.value; + if (!valObj || valObj.type !== "ObjectExpression") continue; + const existingKeys = new Set( + valObj.properties + .filter((p: AstNode) => isObjectProperty(p)) + .map((p: AstNode) => propKeyName(p)), + ); + for (const pk of newPropKeys) { + if (existingKeys.has(pk)) continue; + const defaultVal = backfillDefaults[pk]; + if (defaultVal == null) continue; + const fillProp = parseExpr(`{ ${safeKey(pk)}: ${valueToCode(defaultVal)} }`).properties[0]; + valObj.properties.push(fillProp); + } + } + } + + return recast.print(loc.parsed.ast).code; +} + +/** + * Remove a keyframe at the given percentage. If fewer than 2 keyframes remain + * after removal, collapse the keyframes object to a flat tween using the + * remaining keyframe's properties. + */ +export function removeKeyframeFromScript( + script: string, + animationId: string, + percentage: number, +): string { + // Array-form keyframes (`keyframes: [{x,y}, …]`) have no explicit percentages — + // GSAP distributes them evenly. The object-form path below can't see them + // (findKeyframesObjectNode only matches ObjectExpression), so removing from an + // array-form tween silently no-op'd. Resolve the element by its implicit + // percentage and splice it; collapse to a flat tween when fewer than two remain. + const arrLoc = locateAnimationWithFallback(script, animationId); + // findPropertyNode here returns the property's VALUE node directly. + const arrVal = arrLoc && findPropertyNode(arrLoc.target.call.varsArg, "keyframes"); + if (arrLoc && arrVal?.type === "ArrayExpression") { + const elements: AstNode[] = (arrVal.elements ?? []).filter( + (e: AstNode | null): e is AstNode => !!e && e.type === "ObjectExpression", + ); + const n = elements.length; + if (n === 0) return script; + let matchIdx = -1; + let bestDist = Number.POSITIVE_INFINITY; + for (let i = 0; i < n; i++) { + const pct = n > 1 ? (i / (n - 1)) * 100 : 0; + const dist = Math.abs(pct - percentage); + if (dist <= PCT_TOLERANCE && dist < bestDist) { + matchIdx = i; + bestDist = dist; + } + } + if (matchIdx === -1) return script; + const remaining = elements.filter((_, i) => i !== matchIdx); + if (remaining.length < 2) { + const sole = remaining[0]; + const record = sole ? objectExpressionToRecord(sole, arrLoc.parsed.scope) : {}; + collapseKeyframesToFlat(arrLoc.target.call.varsArg, record); + } else { + const realIdx = arrVal.elements.indexOf(elements[matchIdx]); + arrVal.elements.splice(realIdx, 1); + } + return recast.print(arrLoc.parsed.ast).code; + } + + const ctx = locateKeyframeCtx(script, animationId, percentage); + if (!ctx) return script; + const { loc, kfNode } = ctx; + + const match = findKeyframePropByPct(kfNode, percentage); + if (!match) return script; + const removeIdx = match.idx; + + kfNode.properties.splice(removeIdx, 1); + + const remainingKfs = filterPercentageProps(kfNode); + if (remainingKfs.length < 2) { + const record = + remainingKfs.length === 1 + ? objectExpressionToRecord(remainingKfs[0]!.value, loc.parsed.scope) + : {}; + collapseKeyframesToFlat(loc.target.call.varsArg, record); + } + + return recast.print(loc.parsed.ast).code; +} + +/** + * Replace the properties (and optionally ease) at an existing keyframe percentage. + */ +export function updateKeyframeInScript( + script: string, + animationId: string, + percentage: number, + properties: Record, + ease?: string, +): string { + // Array-form keyframes (`keyframes: [{x,y}, …]`) have no explicit percentages — + // GSAP distributes them evenly. The percentage-keyed object path below can't + // match them (findKeyframesObjectNode only matches ObjectExpression), so dragging + // a motion-path node on an array-authored tween silently no-op'd. Resolve the + // element by its implicit percentage and replace it in place. Mirrors the array + // branch in removeKeyframeFromScript. + const arrLoc = locateAnimationWithFallback(script, animationId); + const arrVal = arrLoc && findPropertyNode(arrLoc.target.call.varsArg, "keyframes"); + if (arrLoc && arrVal?.type === "ArrayExpression") { + const elements: AstNode[] = (arrVal.elements ?? []).filter( + (e: AstNode | null): e is AstNode => !!e && e.type === "ObjectExpression", + ); + const n = elements.length; + if (n === 0) return script; + let matchIdx = -1; + let bestDist = Number.POSITIVE_INFINITY; + for (let i = 0; i < n; i++) { + const pct = n > 1 ? (i / (n - 1)) * 100 : 0; + const dist = Math.abs(pct - percentage); + if (dist <= PCT_TOLERANCE && dist < bestDist) { + matchIdx = i; + bestDist = dist; + } + } + if (matchIdx === -1) return script; + const realIdx = arrVal.elements.indexOf(elements[matchIdx]); + arrVal.elements[realIdx] = buildKeyframeValueNode(properties, ease); + return recast.print(arrLoc.parsed.ast).code; + } + + const ctx = locateKeyframeCtx(script, animationId, percentage); + if (!ctx) return script; + const { loc, kfNode } = ctx; + + const match = findKeyframePropByPct(kfNode, percentage); + if (!match) return script; + + if (Object.keys(properties).length === 0 && ease) { + // Ease-only update: preserve existing properties, just add/replace ease + const existing = match.prop.value; + if (existing?.type === "ObjectExpression") { + const props = (existing.properties ?? []) as AstNode[]; + const easeIdx = props.findIndex( + (p: AstNode) => isObjectProperty(p) && propKeyName(p) === "ease", + ); + const easeNode = parseExpr(`({ ease: ${JSON.stringify(ease)} })`).properties[0]; + if (easeIdx >= 0) { + props[easeIdx] = easeNode; + } else { + props.push(easeNode); + } + return recast.print(loc.parsed.ast).code; + } + // Non-object keyframe value (primitive shorthand, e.g. "50%": "0.5"): there + // is no property bag to merge the ease into. Rebuilding from empty + // `properties` would wipe the primitive — leave the keyframe untouched. + return script; + } + match.prop.value = buildKeyframeValueNode(properties, ease); + return recast.print(loc.parsed.ast).code; +} + +/** Strip editable properties and ease/keyframes keys from a varsArg. */ +function stripEditableAndEase(varsArg: AstNode): void { + // ease is a BUILTIN_VAR_KEY (not editable), so filterEditableKeys won't remove it — + // drop it explicitly before filtering, along with keyframes. + if (varsArg?.type !== "ObjectExpression") return; + varsArg.properties = varsArg.properties.filter((p: AstNode) => { + if (!isObjectProperty(p)) return true; + const key = propKeyName(p); + return key !== "ease" && key !== "keyframes"; + }); + filterEditableKeys(varsArg, () => false); +} + +/** Build and prepend a keyframes property node onto varsArg. */ +function insertKeyframesProp( + varsArg: AstNode, + fromProps: Record, + toProps: Record, + easeEach?: string, +): void { + const fromEntries = Object.entries(fromProps).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`); + const toEntries = Object.entries(toProps).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`); + const easeEntry = easeEach ? `, easeEach: ${JSON.stringify(easeEach)}` : ""; + const kfCode = `{ "0%": { ${fromEntries.join(", ")} }, "100%": { ${toEntries.join(", ")} }${easeEntry} }`; + const kfProp = parseExpr(`{ keyframes: {} }`).properties[0]; + kfProp.value = parseExpr(kfCode); + if (varsArg?.type === "ObjectExpression") varsArg.properties.unshift(kfProp); +} + +/** + * Convert a flat tween (to/from/fromTo) to percentage-keyframes format. + * `resolvedFromValues` supplies the "from" state for `to()` tweens or + * the "to" state for `from()` tweens (the values the DOM would resolve to). + */ +export function convertToKeyframesInScript( + script: string, + animationId: string, + resolvedFromValues?: Record, + setDuration = 1, +): string { + let loc = locateAnimationWithFallback(script, animationId); + if (!loc) return script; + + const anim = loc.target.animation; + if (anim.keyframes) return script; + + const { fromProps, toProps } = resolveConversionProps(anim, resolvedFromValues); + const varsArg = loc.target.call.varsArg; + const originalEase = anim.ease; + + stripEditableAndEase(varsArg); + insertKeyframesProp(varsArg, fromProps, toProps, originalEase || undefined); + + if (originalEase) { + setVarsKey(varsArg, "ease", "none"); + } + + // For from() or fromTo(), convert to to() + if (anim.method === "from" || anim.method === "fromTo") { + loc.target.call.node.callee.property.name = "to"; + if (anim.method === "fromTo") loc.target.call.node.arguments.splice(1, 1); + } + + // A static `set` becomes an animatable `to`: flip the method, drop the + // immediateRender hold marker, and give it a real duration so the keyframes + // span time. This is what makes a static 3D transform keyframeable. + if (anim.method === "set") { + // A GLOBAL `gsap.set(...)` is off-timeline; flipping only the method would + // emit `gsap.to(...)`, which fires once at load and is NOT on the paused + // master timeline (the engine can't seek/render it). Re-root it onto the + // timeline var and add the position arg (a gsap.set has none) so the + // converted tween is seekable. A `tl.set` already has the right object. + const calleeObj = loc.target.call.node.callee.object; + if (anim.global && calleeObj?.type === "Identifier") { + calleeObj.name = loc.parsed.timelineVar; + if (loc.target.call.node.arguments.length < 3) { + loc.target.call.node.arguments.push(parseExpr("0")); + } + } + loc.target.call.node.callee.property.name = "to"; + removeVarsKey(varsArg, "immediateRender"); + setVarsKey(varsArg, "duration", Math.max(0.001, setDuration)); + } + + return recast.print(loc.parsed.ast).code; +} + +/** + * Remove all keyframes from a tween, collapsing to a flat tween with the + * last keyframe's properties. + */ +export function removeAllKeyframesFromScript(script: string, animationId: string): string { + let loc = locateAnimationWithFallback(script, animationId); + if (!loc) return script; + const kfNode = findKeyframesObjectNode(loc.target.call.varsArg); + if (!kfNode) return script; + + const kfEntries = filterPercentageProps(kfNode) + .map((p: AstNode) => ({ pct: percentageFromKey(propKeyName(p)!), prop: p })) + .filter((e) => !Number.isNaN(e.pct)) + .sort((a, b) => a.pct - b.pct); + if (kfEntries.length === 0) return script; + + // For to()/set(): collapse to last keyframe (the destination = visible state). + // For from(): collapse to first keyframe (the starting state). + const method = loc.target.call.method; + const collapseEntry = method === "from" ? kfEntries[0]! : kfEntries[kfEntries.length - 1]!; + const record = objectExpressionToRecord(collapseEntry.prop.value, loc.parsed.scope); + collapseKeyframesToFlat(loc.target.call.varsArg, record); + + return recast.print(loc.parsed.ast).code; +} + +/** + * Replace a dynamic `keyframes: ` with a static percentage-keyframes object. + * Called when the user first edits a dynamically-generated keyframe in the studio. + */ +export function materializeKeyframesInScript( + script: string, + animationId: string, + keyframes: Array<{ + percentage: number; + properties: Record; + ease?: string; + }>, + easeEach?: string, + resolvedSelector?: string, +): string { + let loc = locateAnimationWithFallback(script, animationId); + if (!loc) return script; + + const varsArg = loc.target.call.varsArg; + + // Replace dynamic selector with resolved static string + if (resolvedSelector && loc.target.call.node.arguments[0]) { + loc.target.call.node.arguments[0] = parseExpr(JSON.stringify(resolvedSelector)); + } + + const kfObjCode = buildKeyframeObjectCode(sortedKeyframes(keyframes), { easeEach }); + const kfParent = varsArg.properties.find( + (p: AstNode) => isObjectProperty(p) && propKeyName(p) === "keyframes", + ); + if (kfParent) { + kfParent.value = parseExpr(kfObjCode); + } else { + const kfProp = parseExpr(`{ keyframes: ${kfObjCode} }`).properties[0]; + varsArg.properties.unshift(kfProp); + } + + removeVarsKey(varsArg, "easeEach"); + + return recast.print(loc.parsed.ast).code; +} + +// ── Arc Path (motionPath) AST Mutations ────────────────────────────────── + +function numericXY(props: Record): { x: number; y: number } | null { + const x = props.x; + const y = props.y; + return typeof x === "number" && typeof y === "number" ? { x, y } : null; +} + +function extractArcWaypoints(anim: GsapAnimation): Array<{ x: number; y: number }> { + const kfs = anim.keyframes?.keyframes ?? []; + const waypoints = kfs.map((kf) => numericXY(kf.properties)).filter((p) => p !== null); + if (waypoints.length >= 2) return waypoints; + const px = anim.properties.x; + const py = anim.properties.y; + if (typeof px !== "number" && typeof py !== "number") return waypoints; + return [ + { x: 0, y: 0 }, + { x: typeof px === "number" ? px : 0, y: typeof py === "number" ? py : 0 }, + ]; +} + +function buildMotionPathObjectCode(config: { + waypoints: Array<{ x: number; y: number }>; + segments: ArcPathSegment[]; + autoRotate: boolean | number; +}): string { + const { waypoints, segments, autoRotate } = config; + const hasExplicitControlPoints = segments.some((s) => s.cp1 && s.cp2); + // The simple `path` array supports only one scalar curviness for the whole + // path, so per-segment curviness must use the cubic form (curviness baked into + // each segment's control points). Without this, the simple branch serializes + // only segments[0].curviness and silently drops every other segment's curve. + const curvinessVaries = segments.some( + (s) => (s.curviness ?? 1) !== (segments[0]?.curviness ?? 1), + ); + + let pathEntries: string[]; + if ((hasExplicitControlPoints || curvinessVaries) && waypoints.length >= 2) { + // type: "cubic" — interleave control points: [anchor, cp1, cp2, anchor, ...] + pathEntries = [`{x: ${waypoints[0]!.x}, y: ${waypoints[0]!.y}}`]; + for (let i = 0; i < segments.length; i++) { + const seg = segments[i]!; + const nextWp = waypoints[i + 1]!; + if (seg.cp1 && seg.cp2) { + pathEntries.push(`{x: ${seg.cp1.x}, y: ${seg.cp1.y}}`); + pathEntries.push(`{x: ${seg.cp2.x}, y: ${seg.cp2.y}}`); + } else { + // Auto-generate simple midpoint control points from curviness + const wp = waypoints[i]!; + const dx = nextWp.x - wp.x; + const dy = nextWp.y - wp.y; + const c = seg.curviness ?? 1; + pathEntries.push( + `{x: ${wp.x + dx * 0.33}, y: ${wp.y + dy * 0.33 - c * Math.abs(dx) * 0.25}}`, + ); + pathEntries.push( + `{x: ${wp.x + dx * 0.66}, y: ${wp.y + dy * 0.66 - c * Math.abs(dx) * 0.25}}`, + ); + } + pathEntries.push(`{x: ${nextWp.x}, y: ${nextWp.y}}`); + } + const pathStr = pathEntries.join(", "); + const parts = [`path: [${pathStr}]`, `type: "cubic"`]; + if (autoRotate === true) parts.push("autoRotate: true"); + else if (typeof autoRotate === "number") parts.push(`autoRotate: ${autoRotate}`); + return `{ ${parts.join(", ")} }`; + } + + // Simple waypoint array with curviness + pathEntries = waypoints.map((wp) => `{x: ${wp.x}, y: ${wp.y}}`); + const curviness = segments[0]?.curviness ?? 1; + const parts = [`path: [${pathEntries.join(", ")}]`]; + if (curviness !== 1) parts.push(`curviness: ${curviness}`); + if (autoRotate === true) parts.push("autoRotate: true"); + else if (typeof autoRotate === "number") parts.push(`autoRotate: ${autoRotate}`); + return `{ ${parts.join(", ")} }`; +} + +export function setArcPathInScript( + script: string, + animationId: string, + config: ArcPathConfig, +): string { + const loc = locateAnimation(script, animationId); + if (!loc) return script; + + const varsArg = loc.target.call.varsArg; + const anim = loc.target.animation; + + if (!config.enabled) { + // Disable arc: restore x/y from motionPath's last waypoint, then remove motionPath + const motionPathProp = varsArg.properties.find( + (p: AstNode) => isObjectProperty(p) && propKeyName(p) === "motionPath", + ); + if (motionPathProp) { + const mpVal = motionPathProp.value; + let pathArr: AstNode[] | undefined; + if (mpVal?.type === "ObjectExpression") { + const pathProp = mpVal.properties.find( + (p: AstNode) => isObjectProperty(p) && propKeyName(p) === "path", + ); + if (pathProp?.value?.type === "ArrayExpression") pathArr = pathProp.value.elements; + } + if (pathArr && pathArr.length > 0) { + const last = pathArr[pathArr.length - 1]; + if (last?.type === "ObjectExpression") { + for (const p of last.properties) { + const k = propKeyName(p); + if (k === "x" || k === "y") { + const v = p.value?.value; + if (typeof v === "number") setVarsKey(varsArg, k, v); + } + } + } + } + } + removeVarsKey(varsArg, "motionPath"); + return recast.print(loc.parsed.ast).code; + } + + const waypoints = extractArcWaypoints(anim); + if (waypoints.length < 2) return script; + + // Build segments — use provided segments or create defaults + const segments: ArcPathSegment[] = + config.segments.length === waypoints.length - 1 + ? config.segments + : Array.from({ length: waypoints.length - 1 }, () => ({ curviness: 1 })); + + const motionPathCode = buildMotionPathObjectCode({ + waypoints, + segments, + autoRotate: config.autoRotate, + }); + + // Set motionPath on the vars + const motionPathNode = parseExpr(motionPathCode); + const existingProp = varsArg.properties.find( + (p: AstNode) => isObjectProperty(p) && propKeyName(p) === "motionPath", + ); + if (existingProp) { + existingProp.value = motionPathNode; + } else { + const prop = parseExpr(`{ motionPath: ${motionPathCode} }`).properties[0]; + varsArg.properties.push(prop); + } + + // Strip x/y from keyframes (they're now in motionPath) + const kfNode = findKeyframesObjectNode(varsArg); + if (kfNode) { + for (const pctProp of filterPercentageProps(kfNode)) { + if (pctProp.value?.type === "ObjectExpression") { + pctProp.value.properties = pctProp.value.properties.filter((p: AstNode) => { + const k = propKeyName(p); + return k !== "x" && k !== "y"; + }); + } + } + } + + // Strip flat x/y from vars (they're now in motionPath) + removeVarsKey(varsArg, "x"); + removeVarsKey(varsArg, "y"); + + return recast.print(loc.parsed.ast).code; +} + +export function updateArcSegmentInScript( + script: string, + animationId: string, + segmentIndex: number, + update: Partial, +): string { + const loc = locateAnimation(script, animationId); + if (!loc) return script; + + const anim = loc.target.animation; + if (!anim.arcPath?.enabled) return script; + + const segments = [...anim.arcPath.segments]; + if (segmentIndex < 0 || segmentIndex >= segments.length) return script; + + segments[segmentIndex] = { ...segments[segmentIndex]!, ...update }; + + const waypoints = extractArcWaypoints(anim); + if (waypoints.length < 2) return script; + + const motionPathCode = buildMotionPathObjectCode({ + waypoints, + segments, + autoRotate: anim.arcPath.autoRotate, + }); + + const varsArg = loc.target.call.varsArg; + const existingProp = varsArg.properties.find( + (p: AstNode) => isObjectProperty(p) && propKeyName(p) === "motionPath", + ); + if (existingProp) { + existingProp.value = parseExpr(motionPathCode); + } + + return recast.print(loc.parsed.ast).code; +} + +/** + * Move a single motionPath waypoint (anchor) to a new position. The waypoint + * list is normalized to anchors for both straight and cubic paths, so + * `pointIndex` matches the node order the studio overlay renders; cubic control + * points are preserved. No-op when the animation/arc is missing or the index is + * out of range. + */ +export function updateMotionPathPointInScript( + script: string, + animationId: string, + pointIndex: number, + point: { x: number; y: number }, +): string { + const loc = locateAnimation(script, animationId); + if (!loc) return script; + + const anim = loc.target.animation; + if (!anim.arcPath?.enabled) return script; + + const waypoints = extractArcWaypoints(anim); + if (pointIndex < 0 || pointIndex >= waypoints.length || waypoints.length < 2) return script; + + const nextWaypoints = waypoints.map((wp, i) => + i === pointIndex ? { x: point.x, y: point.y } : wp, + ); + + const motionPathCode = buildMotionPathObjectCode({ + waypoints: nextWaypoints, + segments: anim.arcPath.segments, + autoRotate: anim.arcPath.autoRotate, + }); + + const varsArg = loc.target.call.varsArg; + const existingProp = varsArg.properties.find( + (p: AstNode) => isObjectProperty(p) && propKeyName(p) === "motionPath", + ); + if (existingProp) { + existingProp.value = parseExpr(motionPathCode); + } + + return recast.print(loc.parsed.ast).code; +} + +/** True when any segment carries explicit cubic control points. Add/remove are + * restricted to curviness (non-cubic) paths — synthesizing control points for + * an inserted cubic anchor is out of scope. */ +function hasCubicSegments(segments: ArcPathSegment[]): boolean { + return segments.some((s) => s.cp1 != null || s.cp2 != null); +} + +function writeMotionPathValue( + loc: NonNullable>, + waypoints: Array<{ x: number; y: number }>, + segments: ArcPathSegment[], + autoRotate: boolean | number, +): string { + const motionPathCode = buildMotionPathObjectCode({ waypoints, segments, autoRotate }); + const varsArg = loc.target.call.varsArg; + const existingProp = varsArg.properties.find( + (p: AstNode) => isObjectProperty(p) && propKeyName(p) === "motionPath", + ); + if (existingProp) existingProp.value = parseExpr(motionPathCode); + return recast.print(loc.parsed.ast).code; +} + +/** + * Insert a waypoint at `index` (between existing anchors), splitting the segment + * it lands on so the new neighbor inherits its curviness. Non-cubic paths only. + * No-op for missing animation/arc, out-of-range index, or cubic paths. + */ +export function addMotionPathPointInScript( + script: string, + animationId: string, + index: number, + point: { x: number; y: number }, +): string { + const loc = locateAnimation(script, animationId); + if (!loc) return script; + const anim = loc.target.animation; + if (!anim.arcPath?.enabled || hasCubicSegments(anim.arcPath.segments)) return script; + + const waypoints = extractArcWaypoints(anim); + // Insert strictly between two anchors: index 1..length-1. + if (index < 1 || index > waypoints.length - 1) return script; + + const segments = [...anim.arcPath.segments]; + waypoints.splice(index, 0, { x: point.x, y: point.y }); + const splitCurviness = segments[index - 1]?.curviness ?? 1; + segments.splice(index - 1, 0, { curviness: splitCurviness }); + + return writeMotionPathValue(loc, waypoints, segments, anim.arcPath.autoRotate); +} + +/** + * Remove the waypoint at `index`. Refuses to drop below two anchors (a path + * can't have fewer). Non-cubic paths only. No-op for missing animation/arc, + * out-of-range index, cubic paths, or a 2-point path. + */ +export function removeMotionPathPointInScript( + script: string, + animationId: string, + index: number, +): string { + const loc = locateAnimation(script, animationId); + if (!loc) return script; + const anim = loc.target.animation; + if (!anim.arcPath?.enabled || hasCubicSegments(anim.arcPath.segments)) return script; + + const waypoints = extractArcWaypoints(anim); + if (waypoints.length <= 2 || index < 0 || index >= waypoints.length) return script; + + const segments = [...anim.arcPath.segments]; + waypoints.splice(index, 1); + // Drop the segment on the side that still exists (last anchor → preceding segment). + segments.splice(Math.min(index, segments.length - 1), 1); + + return writeMotionPathValue(loc, waypoints, segments, anim.arcPath.autoRotate); +} + +/** + * Author a fresh 2-anchor motionPath tween on a target element: a straight line + * from the element's home (0,0) to `point`, gentle ease, ready for waypoint + * editing. Mirrors `addAnimationWithKeyframesToScript`. + */ +export function addMotionPathToScript( + script: string, + targetSelector: string, + position: number, + duration: number, + point: { x: number; y: number }, + ease = "power1.inOut", +): { script: string; id: string | null } { + // `id: null` on the failure paths is a deliberate sentinel: callers must + // null-check before chaining (e.g. locating the new tween). An empty string + // would silently flow into selector/locate calls and match nothing. + let parsed: ParsedGsapAst; + try { + parsed = parseGsapAst(script); + } catch (e) { + console.warn("[gsap-parser] addMotionPathToScript parse failed:", e); + return { script, id: null }; + } + if (parsed.located.length === 0 && parsed.detection.timelineVar === null) { + return { script, id: null }; + } + + const motionPathCode = buildMotionPathObjectCode({ + waypoints: [ + { x: 0, y: 0 }, + { x: point.x, y: point.y }, + ], + segments: [{ curviness: 1 }], + autoRotate: false, + }); + const selector = JSON.stringify(targetSelector); + const varEntries = [ + `motionPath: ${motionPathCode}`, + `duration: ${valueToCode(duration)}`, + `ease: ${JSON.stringify(ease)}`, + ]; + const stmtCode = `${parsed.timelineVar}.to(${selector}, { ${varEntries.join(", ")} }, ${valueToCode(position)});`; + const newStatement = parseScript(stmtCode).program.body[0]; + insertAfterAnchor(parsed, newStatement); + + const result = recast.print(parsed.ast).code; + const reParsed = parseGsapAst(result); + const newId = reParsed.located[reParsed.located.length - 1]?.id ?? null; + return { script: result, id: newId }; +} + +export function removeArcPathFromScript(script: string, animationId: string): string { + return setArcPathInScript(script, animationId, { + enabled: false, + autoRotate: false, + segments: [], + }); +} + +// ── Split Into Property Groups ──────────────────────────────────────────── + +/** + * Split a multi-group tween into separate per-group tweens. Each resulting + * tween contains only properties belonging to one property group (position, + * scale, rotation, visual, etc.). `transformOrigin` stays with the group that + * has the most properties. If the tween already belongs to a single group, + * returns the script unchanged with the original ID. + */ +// fallow-ignore-next-line complexity +export function splitIntoPropertyGroups( + script: string, + animationId: string, +): { script: string; ids: string[] } { + let loc = locateAnimationWithFallback(script, animationId); + if (!loc) return { script, ids: [animationId] }; + + const anim = loc.target.animation; + + // Collect the properties to partition. For keyframed tweens, gather the + // union of all properties across all keyframes. For flat tweens, use the + // tween's own properties map. + const allPropKeys = new Set(); + if (anim.keyframes) { + for (const kf of anim.keyframes.keyframes) { + for (const k of Object.keys(kf.properties)) allPropKeys.add(k); + } + } else { + for (const k of Object.keys(anim.properties)) allPropKeys.add(k); + } + + // Partition properties into groups (excluding transformOrigin — handled below). + const groupProps = new Map(); + for (const key of allPropKeys) { + if (key === "transformOrigin") continue; + const group = classifyPropertyGroup(key); + let arr = groupProps.get(group); + if (!arr) { + arr = []; + groupProps.set(group, arr); + } + arr.push(key); + } + + // Only one group (or zero) — no split needed. + if (groupProps.size <= 1) return { script, ids: [anim.id] }; + + // Assign transformOrigin to the group with the most properties. + if (allPropKeys.has("transformOrigin")) { + let largestGroup: PropertyGroupName | undefined; + let largestCount = 0; + for (const [group, props] of groupProps) { + if (props.length > largestCount) { + largestCount = props.length; + largestGroup = group; + } + } + if (largestGroup) { + groupProps.get(largestGroup)!.push("transformOrigin"); + } + } + + // Build per-group tweens and insert them, then remove the original. + let result = script; + + // Remove the original tween first. + result = removeAnimationFromScript(result, anim.id); + + // Insert one tween per group. Iteration order of the Map follows insertion + // order, which mirrors the order properties were encountered. + for (const [, props] of groupProps) { + const propSet = new Set(props); + + if (anim.keyframes) { + // Build keyframes containing only this group's properties per keyframe. + const groupKeyframes: Array<{ + percentage: number; + properties: Record; + ease?: string; + auto?: boolean; + }> = []; + + for (const kf of anim.keyframes.keyframes) { + const filtered: Record = {}; + for (const [k, v] of Object.entries(kf.properties)) { + if (propSet.has(k)) filtered[k] = v; + } + // Skip keyframes where this group has zero properties. + if (Object.keys(filtered).length === 0) continue; + groupKeyframes.push({ + percentage: kf.percentage, + properties: filtered, + ...(kf.ease ? { ease: kf.ease } : {}), + }); + } + + if (groupKeyframes.length === 0) continue; + + const addResult = addAnimationWithKeyframesToScript( + result, + anim.targetSelector, + typeof anim.position === "number" ? anim.position : 0, + anim.duration ?? 0.5, + groupKeyframes, + anim.keyframes.easeEach ?? anim.ease, + ); + result = addResult.script; + } else { + // Flat tween — filter properties to this group. + const groupProperties: Record = {}; + for (const [k, v] of Object.entries(anim.properties)) { + if (propSet.has(k)) groupProperties[k] = v; + } + if (Object.keys(groupProperties).length === 0) continue; + + let fromProperties: Record | undefined; + if (anim.method === "fromTo" && anim.fromProperties) { + fromProperties = {}; + for (const [k, v] of Object.entries(anim.fromProperties)) { + if (propSet.has(k)) fromProperties[k] = v; + } + } + + const addResult = addAnimationToScript(result, { + targetSelector: anim.targetSelector, + method: anim.method, + position: anim.position, + duration: anim.duration, + ease: anim.ease, + properties: groupProperties, + fromProperties, + extras: anim.extras, + }); + result = addResult.script; + } + } + + // Re-parse to collect the new IDs. + const reParsed = parseGsapAst(result); + const newIds = reParsed.located + .filter((l) => l.animation.targetSelector === anim.targetSelector) + .map((l) => l.id); + + return { script: result, ids: newIds }; +} + +/** + * Replace a dynamic loop that generates multiple tween calls with individual + * static `tl.to()` calls — one per element. Finds the loop containing the + * animation and replaces the entire loop body with unrolled static calls. + */ +export function unrollDynamicAnimations( + script: string, + animationId: string, + elements: Array<{ + selector: string; + keyframes: Array<{ percentage: number; properties: Record }>; + easeEach?: string; + }>, +): string { + const loc = locateAnimation(script, animationId); + if (!loc) return script; + + const varsArg = loc.target.call.varsArg; + + // Read duration and ease from the original tween vars + const durationVal = extractLiteralValue(findPropertyNode(varsArg, "duration"), loc.parsed.scope); + const easeVal = extractLiteralValue(findPropertyNode(varsArg, "ease"), loc.parsed.scope); + const duration = typeof durationVal === "number" ? durationVal : 8; + const ease = typeof easeVal === "string" ? easeVal : "none"; + const posArg = loc.target.call.positionArg; + const position = posArg ? extractLiteralValue(posArg, loc.parsed.scope) : 0; + const posCode = + typeof position === "number" + ? String(position) + : typeof position === "string" + ? JSON.stringify(position) + : "0"; + + // Find the enclosing loop (for/forEach) by walking up the AST path + let loopNode: AstNode | null = null; + let current = loc.target.call.path; + while (current) { + const node = current.node ?? current.value; + if ( + node?.type === "ForStatement" || + node?.type === "ForInStatement" || + node?.type === "ForOfStatement" || + node?.type === "WhileStatement" + ) { + loopNode = node; + break; + } + if ( + node?.type === "ExpressionStatement" && + node.expression?.type === "CallExpression" && + node.expression.callee?.property?.name === "forEach" + ) { + loopNode = node; + break; + } + current = current.parent ?? current.parentPath; + } + + // Build replacement code: individual tl.to() calls for each element + const calls: string[] = []; + for (const el of elements) { + const kfCode = buildKeyframeObjectCode(sortedKeyframes(el.keyframes), { + easeEach: el.easeEach, + }); + calls.push( + `${loc.parsed.timelineVar}.to(${JSON.stringify(el.selector)}, { keyframes: ${kfCode}, duration: ${duration}, ease: ${JSON.stringify(ease)} }, ${posCode});`, + ); + } + + const replacement = calls.join("\n "); + + if (loopNode) { + // Replace the entire loop with the unrolled calls + const start = loopNode.start ?? loopNode.range?.[0]; + const end = loopNode.end ?? loopNode.range?.[1]; + if (typeof start === "number" && typeof end === "number") { + return script.slice(0, start) + replacement + script.slice(end); + } + } + + // Fallback: replace just the tween call's enclosing expression statement + const stmtNode = loc.target.call.path?.parent?.node ?? loc.target.call.path?.parentPath?.node; + if (stmtNode?.type === "ExpressionStatement") { + const start = stmtNode.start ?? stmtNode.range?.[0]; + const end = stmtNode.end ?? stmtNode.range?.[1]; + if (typeof start === "number" && typeof end === "number") { + return script.slice(0, start) + replacement + script.slice(end); + } + } + + return script; +} diff --git a/packages/core/src/parsers/gsapParserAcorn.computed.test.ts b/packages/parsers/src/gsapParserAcorn.computed.test.ts similarity index 100% rename from packages/core/src/parsers/gsapParserAcorn.computed.test.ts rename to packages/parsers/src/gsapParserAcorn.computed.test.ts diff --git a/packages/core/src/parsers/gsapParserAcorn.full.test.ts b/packages/parsers/src/gsapParserAcorn.full.test.ts similarity index 100% rename from packages/core/src/parsers/gsapParserAcorn.full.test.ts rename to packages/parsers/src/gsapParserAcorn.full.test.ts diff --git a/packages/parsers/src/gsapParserAcorn.ts b/packages/parsers/src/gsapParserAcorn.ts new file mode 100644 index 0000000000..38ca4cb8c0 --- /dev/null +++ b/packages/parsers/src/gsapParserAcorn.ts @@ -0,0 +1,1231 @@ +// 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(["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; +/** Per-scope element bindings: scopeNode → (variable name → selector). */ +type TargetBindings = Map>; + +// ── Value resolution ───────────────────────────────────────────────────────── + +// fallow-ignore-next-line complexity +function resolveNode( + node: any, + scope: ReadonlyMap, +): 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; + } + 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(); + acornWalk.simple(ast, { + VariableDeclarator(node: any) { + const name = node.id?.name; + const init = node.init; + if (name && init) { + const val = resolveNode(init, bindings); + if (val !== 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); + + 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; +} + +// ── 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 { + const result: Record = {}; + 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; +} + +interface TimelineDetection { + timelineVar: string | null; + timelineCount: number; + defaults?: TimelineDefaults; +} + +// 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 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 (!timelineVar) { + timelineVar = node.id?.name ?? null; + defaults = extractTimelineDefaults(node.init, emptyScope); + } + } + }, + AssignmentExpression(node: any) { + if (isGsapTimelineCall(node.right)) { + timelineCount += 1; + if (!timelineVar) { + const left = node.left; + if (left?.type === "Identifier") timelineVar = left.name; + defaults = extractTimelineDefaults(node.right, emptyScope); + } + } + }, + }); + + return { timelineVar, 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 callee chain is rooted at the timeline variable. */ +function isTimelineRootedCall(callNode: any, timelineVar: string): boolean { + let obj = callNode.callee?.object; + while (obj?.type === "CallExpression") { + obj = obj.callee?.object; + } + return obj?.type === "Identifier" && obj.name === timelineVar; +} + +/** + * 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, + timelineVar: string, + 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 a static value round-trips and re-edits in place. + // STRING-LITERAL selectors only: 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, timelineVar) || 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 = {}; + 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; + duration?: number; + ease?: string; + }> = []; + + for (const el of elements) { + if (!el || el.type !== "ObjectExpression") continue; + const record = objectExpressionToRecord(el, scope, source); + const properties: Record = {}; + 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 = 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 = {}; + 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 { + const vars = objectExpressionToRecord(call.varsArg, scope, source); + const properties: Record = {}; + const extras: Record = {}; + 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 | 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 : 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); + } + + const anim: Omit = { + targetSelector: call.selector, + method: call.method, + position, + properties, + fromProperties, + duration, + ease, + }; + if (!hasPositionArg) anim.implicitPosition = true; + let group = classifyTweenPropertyGroup(properties); + if (!group && keyframesData) { + const kfProps: Record = {}; + 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 (call.selector === "__unresolved__") anim.hasUnresolvedSelector = true; + const provenance = readProvenance(call.node); + if (provenance) anim.provenance = provenance; + return anim; +} + +// ── 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; +} + +function applyTimelineDefaults( + anims: Omit[], + 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; + } + } +} + +// fallow-ignore-next-line complexity +function resolveTimelinePositions(anims: Omit[]): void { + let cursor = 0; + let prevStart = 0; + for (const anim of anims) { + // A global `gsap.set(...)` is off-timeline — applied once at load, not + // sequenced on the master timeline. It carries no position arg, so the + // cursor fallback would otherwise hand it the comp-end time. Pin it to 0 + // (its load-time start) and don't advance the cursor/prevStart. + if (anim.method === "set" && anim.global) { + anim.resolvedStart = 0; + continue; + } + const duration = anim.method === "set" ? 0 : (anim.duration ?? GSAP_DEFAULT_DURATION); + let start: number | null; + + if (anim.implicitPosition) { + start = cursor; + } else if (typeof anim.position === "number") { + start = anim.position; + } else if (typeof anim.position === "string") { + start = resolvePositionString(anim.position, cursor, prevStart); + } else { + start = cursor; + } + + if (start != null) { + anim.resolvedStart = Math.max(0, start); + prevStart = anim.resolvedStart; + cursor = Math.max(cursor, anim.resolvedStart + duration); + } + } +} + +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[] { + const counts = new Map(); + 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 timelineVar = detection.timelineVar ?? "tl"; + const calls = findAllTweenCalls(ast, timelineVar, scope, targetBindings); + sortBySourcePosition(calls); + const rawAnims = calls.map((call) => tweenCallToAnimation(call, scope, script)); + applyTimelineDefaults(rawAnims, detection.defaults); + resolveTimelinePositions(rawAnims); + const animations = assignStableIds(rawAnims); + const located = calls.map((call, i) => ({ + id: animations[i]!.id, + call, + animation: animations[i]!, + })); + return { ast, timelineVar, hasTimeline: detection.timelineVar !== 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 timelineVar = detection.timelineVar ?? "tl"; + // 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. + try { + inlineComputedTimelines(ast, timelineVar, (node) => resolveNode(node, scope)); + } catch { + /* fall back to current behavior */ + } + const targetBindings = collectTargetBindings(ast, scope); + const calls = findAllTweenCalls(ast, timelineVar, scope, targetBindings); + sortBySourcePosition(calls); + const rawAnims = calls.map((call) => tweenCallToAnimation(call, scope, script)); + applyTimelineDefaults(rawAnims, detection.defaults); + resolveTimelinePositions(rawAnims); + const animations = assignStableIds(rawAnims); + + const timelineMatch = script.match( + new RegExp( + `^[\\s\\S]*?(?:const|let|var)\\s+${timelineVar}\\s*=\\s*gsap\\.timeline\\s*\\([^)]*\\)\\s*;?`, + ), + ); + const preamble = + timelineMatch?.[0] ?? `const ${timelineVar} = gsap.timeline({ paused: true });`; + + 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.timelineVar === 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 timelineVar = detection.timelineVar ?? "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 tl.addLabel(...) + if ( + callee?.type !== "MemberExpression" || + callee.object?.name !== timelineVar || + 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 []; + } +} diff --git a/packages/parsers/src/gsapParserExports.ts b/packages/parsers/src/gsapParserExports.ts new file mode 100644 index 0000000000..5917d13ded --- /dev/null +++ b/packages/parsers/src/gsapParserExports.ts @@ -0,0 +1,47 @@ +/** + * @hyperframes/core/gsap-parser subpath entry. + * + * Re-exports all public types and helpers that external packages (studio, sdk, + * registry) import via the `@hyperframes/core/gsap-parser` subpath. + * + * The recast-based AST parser (gsapParser.ts) was retired in WS-3.F. The read + * path now uses `parseGsapScriptAcorn` from gsapParserAcorn; the write path + * uses gsapWriterAcorn. This file remains the stable public surface for types + * and serialize helpers. + */ +export type { + GsapAnimation, + GsapMethod, + GsapKeyframesData, + GsapPercentageKeyframe, + ParsedGsap, + ArcPathConfig, + ArcPathSegment, + GsapProvenanceKind, + GsapProvenance, + KeyframeEditability, +} from "./gsapSerialize.js"; +export { + serializeGsapAnimations, + getAnimationsForElementId, + validateCompositionGsap, + keyframesToGsapAnimations, + gsapAnimationsToKeyframes, + editabilityForProvenance, + SUPPORTED_PROPS, + SUPPORTED_EASES, +} from "./gsapSerialize.js"; +// Studio position-hold predicate (`tl.set(...,{data:"hf-hold"})`). A pure +// GsapAnimation helper — re-exported here so studio can filter holds via the +// public entry even though gsapParser.ts is otherwise an internal module. +export { isStudioHoldSet } from "./gsapParser.js"; +export type { PropertyGroupName } from "./gsapConstants.js"; +export { + PROPERTY_GROUPS, + classifyPropertyGroup, + classifyTweenPropertyGroup, +} from "./gsapConstants.js"; +export { generateSpringEaseData, SPRING_PRESETS } from "./springEase.js"; +export type { SpringPreset } from "./springEase.js"; +export { parseGsapScriptAcorn as parseGsapScript } from "./gsapParserAcorn.js"; +export type { SplitAnimationsOptions, SplitAnimationsResult } from "./gsapSerialize.js"; diff --git a/packages/parsers/src/gsapSerialize.ts b/packages/parsers/src/gsapSerialize.ts new file mode 100644 index 0000000000..0595961bdd --- /dev/null +++ b/packages/parsers/src/gsapSerialize.ts @@ -0,0 +1,604 @@ +/** + * Recast-free GSAP helpers: serialization, keyframe<->animation conversion, + * validation, and shared types. + * + * This module MUST NOT import recast / @babel/parser. It is part of the + * isomorphic core layer that the barrel and browser code depend on. AST + * parsing of GSAP source lives in the Node-only `./gsapParser` module. + */ +import type { Keyframe, KeyframeProperties, ValidationResult } from "./types.js"; +import type { PropertyGroupName } from "./gsapConstants"; + +export type GsapMethod = "set" | "to" | "from" | "fromTo"; + +/** How a tween was constructed in source — drives display classification and editability. */ +export type GsapProvenanceKind = "literal" | "helper" | "loop" | "runtime-dynamic"; + +/** + * Origin of a parsed tween. `literal` tweens map 1:1 to a source call and edit + * directly; `helper`/`loop` tweens are expanded from a reused construct (unroll + * to edit); `runtime-dynamic` tweens come from live introspection (override to + * edit). Absent provenance is treated as `literal`. + */ +export interface GsapProvenance { + kind: GsapProvenanceKind; + /** Helper function name (kind === "helper"). */ + fn?: string; + /** 1-based ordinal of the originating call site / loop construct in source order. */ + callSite?: number; + /** 0-based iteration index (kind === "loop"). */ + iteration?: number; + /** Source offset [start, end] of the originating call/loop, when known. */ + sourceRange?: [number, number]; +} + +/** How a tween's keyframes can be edited, derived from its provenance. */ +export type KeyframeEditability = "direct" | "unroll" | "source"; + +/** + * Map provenance to an editing strategy: + * - `direct` — literal tween, maps 1:1 to source; edit in place. + * - `unroll` — helper/loop expansion; unroll to literal tweens, then edit. + * - `source` — runtime-dynamic value; not statically editable, edit the code. + */ +export function editabilityForProvenance(provenance?: GsapProvenance): KeyframeEditability { + if (!provenance || provenance.kind === "literal") return "direct"; + if (provenance.kind === "runtime-dynamic") return "source"; + return "unroll"; +} + +export interface GsapAnimation { + id: string; + targetSelector: string; + method: GsapMethod; + position: number | string; + properties: Record; + fromProperties?: Record; + duration?: number; + ease?: string; + /** Non-editable GSAP config (stagger, yoyo, repeat, etc.) preserved for round-trips. */ + extras?: Record; + /** Native GSAP keyframes data — present when the tween uses keyframes: { ... }. */ + keyframes?: GsapKeyframesData; + /** Arc motion path config — present when the tween uses motionPath for curved position interpolation. */ + arcPath?: ArcPathConfig; + /** True when the tween has a `keyframes` property that couldn't be statically resolved (dynamic). */ + hasUnresolvedKeyframes?: boolean; + /** True when the tween's target selector couldn't be statically resolved (dynamic). */ + hasUnresolvedSelector?: boolean; + /** Absolute start time computed by walking the timeline chain (handles +=, -=, <, >, labels). */ + resolvedStart?: number; + /** True when no position arg was authored — the tween is sequentially placed by GSAP. */ + implicitPosition?: boolean; + /** Which property group this tween belongs to (position, scale, size, rotation, visual, other). + * Undefined for legacy mixed tweens that bundle multiple groups. */ + propertyGroup?: PropertyGroupName; + /** True for a base `gsap.set(...)` (a static hold that runs immediately, OFF the + * timeline) rather than `tl.set(...)`. Carries no timeline position and shows no + * keyframe marker — used to persist a static value (e.g. a 3D transform) without + * introducing a 0% keyframe. */ + global?: boolean; + /** How this tween was constructed in source. Absent ⇒ literal. */ + provenance?: GsapProvenance; +} + +export interface GsapPercentageKeyframe { + percentage: number; + properties: Record; + ease?: string; +} + +export type GsapKeyframeFormat = "percentage" | "object-array" | "simple-array"; + +export interface GsapKeyframesData { + format: GsapKeyframeFormat; + keyframes: GsapPercentageKeyframe[]; + ease?: string; + easeEach?: string; +} + +export interface ArcPathSegment { + curviness: number; + cp1?: { x: number; y: number }; + cp2?: { x: number; y: number }; +} + +export interface ArcPathConfig { + enabled: boolean; + autoRotate: boolean | number; + segments: ArcPathSegment[]; +} + +export interface MotionPathShape { + arcPath: ArcPathConfig; + waypoints: Array<{ x: number; y: number }>; +} + +/** + * Build arcPath segments + waypoints from resolved path coordinates. Shared by + * the AST parser (coords from literal nodes) and the runtime scanner (coords + * from a live `vars.motionPath`), so both produce identical arc config. + */ +export function buildArcPath( + coords: Array<{ x: number; y: number }>, + curviness: number, + autoRotate: boolean | number, + isCubic: boolean, +): MotionPathShape | undefined { + const first = coords[0]; + if (coords.length < 2 || !first) return undefined; + const segments: ArcPathSegment[] = []; + let waypoints: Array<{ x: number; y: number }>; + if (isCubic && coords.length >= 4) { + // coords are [anchor, cp1, cp2, anchor, cp1, cp2, anchor, ...]. + waypoints = [first]; + for (let i = 1; i + 2 < coords.length; i += 3) { + const cp1 = coords[i]; + const cp2 = coords[i + 1]; + const anchor = coords[i + 2]; + if (!cp1 || !cp2 || !anchor) continue; + waypoints.push(anchor); + segments.push({ curviness, cp1, cp2 }); + } + } else { + waypoints = coords; + for (let i = 0; i < waypoints.length - 1; i++) segments.push({ curviness }); + } + return { arcPath: { enabled: true, autoRotate, segments }, waypoints }; +} + +export interface ParsedGsap { + animations: GsapAnimation[]; + timelineVar: string; + preamble: string; + postamble: string; + multipleTimelines?: boolean; + unsupportedTimelinePattern?: boolean; +} + +export { SUPPORTED_PROPS, SUPPORTED_EASES } from "./gsapConstants"; + +// ── Split-animation types (used by gsapWriterAcorn) ───────────────────────── + +export interface SplitAnimationsOptions { + originalId: string; + newId: string; + splitTime: number; + elementStart: number; + elementDuration: number; +} + +export interface SplitAnimationsResult { + script: string; + /** Non-ID-selector animations that the engine cannot safely retarget. */ + skippedSelectors: string[]; +} + +// ── Serialization ─────────────────────────────────────────────────────────── + +export function serializeGsapAnimations( + animations: GsapAnimation[], + timelineVar = "tl", + options?: { includeMediaSync?: boolean; preamble?: string; postamble?: string }, +): string { + const sorted = [...animations].sort((a, b) => { + const aNum = + a.resolvedStart ?? (typeof a.position === "number" ? a.position : Number.MAX_SAFE_INTEGER); + const bNum = + 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 = { ...anim.properties }; + if (anim.duration !== undefined) props.duration = anim.duration; + if (anim.ease) props.ease = anim.ease; + let propsStr = serializeObject(props); + if (anim.extras && Object.keys(anim.extras).length > 0) { + const extrasStr = serializeExtras(anim.extras); + if (Object.keys(props).length === 0) { + propsStr = `{ ${extrasStr} }`; + } else { + // Insert extras before the closing brace + propsStr = propsStr.slice(0, -2) + `, ${extrasStr} }`; + } + } + const posStr = typeof anim.position === "string" ? `"${anim.position}"` : anim.position; + switch (anim.method) { + case "set": + // A global set is a base `gsap.set` — off the timeline, no position arg. + return anim.global + ? ` gsap.set(${selector}, ${propsStr});` + : ` ${timelineVar}.set(${selector}, ${propsStr}, ${posStr});`; + case "to": + return ` ${timelineVar}.to(${selector}, ${propsStr}, ${posStr});`; + case "from": + return ` ${timelineVar}.from(${selector}, ${propsStr}, ${posStr});`; + case "fromTo": { + const fromStr = serializeObject(anim.fromProperties || {}); + return ` ${timelineVar}.fromTo(${selector}, ${fromStr}, ${propsStr}, ${posStr});`; + } + } + }); + + let mediaSync = ""; + if (options?.includeMediaSync) { + mediaSync = ` + ${timelineVar}.eventCallback("onUpdate", function() { + const time = ${timelineVar}.time(); + document.querySelectorAll("video[data-start], audio[data-start]").forEach(function(media) { + const start = parseFloat(media.dataset.start); + const end = parseFloat(media.dataset.end) || Infinity; + const mediaTime = time - start; + if (time >= start && time < end) { + if (Math.abs(media.currentTime - mediaTime) > 0.1) { + media.currentTime = mediaTime; + } + if (media.paused && !${timelineVar}.paused()) { + media.play().catch(function() {}); + } + } else if (!media.paused) { + media.pause(); + } + }); + });`; + } + + const preamble = options?.preamble || `const ${timelineVar} = gsap.timeline({ paused: true });`; + const postamble = options?.postamble ? `\n ${options.postamble}` : ""; + + return ` + ${preamble} +${lines.join("\n")}${mediaSync}${postamble} + `; +} + +export function serializeValue(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); +} + +export function safeJsKey(key: string): string { + return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key) ? key : JSON.stringify(key); +} + +function serializeObject(obj: Record): string { + const entries = Object.entries(obj).map(([key, value]) => { + return `${safeJsKey(key)}: ${serializeValue(value)}`; + }); + return `{ ${entries.join(", ")} }`; +} + +function serializeExtras(extras: Record): string { + return Object.entries(extras) + .map(([key, value]) => { + return `${safeJsKey(key)}: ${serializeValue(value)}`; + }) + .join(", "); +} + +// ── Element filtering ───────────────────────────────────────────────────────── + +/** + * Filter animations to those targeting `#` (id-only match). For the + * studio panel's id-OR-selector matching, see `getAnimationsForElement` in + * `useGsapTweenCache.ts` — distinct on purpose, hence the distinct name. + */ +export function getAnimationsForElementId( + animations: GsapAnimation[], + elementId: string, +): GsapAnimation[] { + const selector = `#${elementId}`; + return animations.filter((a) => a.targetSelector === selector); +} + +// ── Validation (regex-based, no AST needed) ───────────────────────────────── + +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: /\.addPause\s*\(/, message: "addPause() method not allowed" }, + { pattern: /gsap\.registerEffect\s*\(/, message: "registerEffect() not allowed" }, + { pattern: /ScrollTrigger/, message: "ScrollTrigger not allowed" }, + { pattern: /onComplete\s*:/, message: "onComplete callback not allowed" }, + { pattern: /onUpdate\s*:/, message: "onUpdate callback not allowed" }, + { pattern: /onStart\s*:/, message: "onStart callback not allowed" }, + { pattern: /onRepeat\s*:/, message: "onRepeat callback not allowed" }, + { pattern: /onReverseComplete\s*:/, message: "onReverseComplete callback not allowed" }, + { pattern: /repeat\s*:\s*-1/, message: "Infinite repeat (repeat: -1) not allowed" }, + { pattern: /Math\.random\s*\(/, message: "Random values (Math.random) not allowed" }, + { pattern: /Date\.now\s*\(/, message: "Date-dependent values (Date.now) not allowed" }, + { pattern: /new\s+Date\s*\(/, message: "Date constructor not allowed" }, + { pattern: /setTimeout\s*\(/, message: "setTimeout not allowed" }, + { pattern: /setInterval\s*\(/, message: "setInterval not allowed" }, + { pattern: /requestAnimationFrame\s*\(/, message: "requestAnimationFrame not allowed" }, +]; + +export function validateCompositionGsap(script: string): ValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + for (const { pattern, message } of FORBIDDEN_GSAP_PATTERNS) { + if (pattern.test(script)) errors.push(message); + } + if (/yoyo\s*:\s*true/.test(script)) { + warnings.push("yoyo animations may behave unexpectedly when scrubbing"); + } + if (/stagger\s*:/.test(script)) { + warnings.push("stagger animations may not serialize correctly"); + } + return { valid: errors.length === 0, errors, warnings }; +} + +// ── Keyframe Conversion Helpers ───────────────────────────────────────────── + +export function keyframesToGsapAnimations( + elementId: string, + keyframes: Keyframe[], + elementStartTime: number, + base?: { x?: number; y?: number; scale?: number }, +): GsapAnimation[] { + const sorted = [...keyframes].sort((a, b) => a.time - b.time); + const animations: GsapAnimation[] = []; + const baseX = base?.x ?? 0; + 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; + const prevKf = i > 0 ? sorted[i - 1] : null; + const duration = prevKf ? kf.time - prevKf.time : undefined; + const position = prevKf ? elementStartTime + prevKf.time : absoluteTime; + + const properties: Record = {}; + for (const [key, value] of Object.entries(kf.properties)) { + if (typeof value !== "number") continue; + if (key === "x") properties.x = baseX + value; + else if (key === "y") properties.y = baseY + value; + else if (key === "scale") properties.scale = baseScale * value; + else properties[key] = value; + } + + animations.push({ + id: `${elementId}-kf-${kf.id}`, + targetSelector: `#${elementId}`, + method: isFirst ? "set" : "to", + position, + properties, + duration: isFirst ? undefined : duration, + ease: kf.ease, + }); + }); + + return animations; +} + +export function gsapAnimationsToKeyframes( + animations: GsapAnimation[], + elementStartTime: number, + options?: { + baseX?: number; + baseY?: number; + baseScale?: number; + clampTimeToZero?: boolean; + skipBaseSet?: boolean; + }, +): Keyframe[] { + const validMethods: GsapMethod[] = ["set", "to", "from", "fromTo"]; + const baseX = options?.baseX ?? 0; + const baseY = options?.baseY ?? 0; + const baseScale = options?.baseScale ?? 1; + const clampTimeToZero = options?.clampTimeToZero ?? true; + const skipBaseSet = options?.skipBaseSet ?? false; + const baseTimeEpsilon = 0.001; + const baseValueEpsilon = 0.00001; + + return ( + animations + .filter( + (a): a is GsapAnimation & { position: number } => + validMethods.includes(a.method) && typeof a.position === "number", + ) + // fallow-ignore-next-line complexity + .map((a) => { + const relativeTimeRaw = a.position - elementStartTime; + const time = clampTimeToZero ? Math.max(0, relativeTimeRaw) : relativeTimeRaw; + + const properties: Partial = {}; + for (const [key, value] of Object.entries(a.properties)) { + if (typeof value !== "number") continue; + if (key === "x") properties.x = value - baseX; + else if (key === "y") properties.y = value - baseY; + else if (key === "scale") { + properties.scale = baseScale !== 0 ? value / baseScale : value; + } else { + (properties as Record)[key] = value; + } + } + + if ( + skipBaseSet && + a.method === "set" && + time < baseTimeEpsilon && + Object.values(properties).every( + (v) => typeof v === "number" && Math.abs(v) < baseValueEpsilon, + ) + ) { + return null; + } + + return { + id: a.id.replace(/^.*-kf-/, ""), + time, + properties: properties as KeyframeProperties, + ease: a.ease, + }; + }) + .filter((kf): kf is NonNullable => kf !== null) + ); +} + +// ── Keyframe-conversion transforms (pure; shared by recast + acorn writers) ──── + +/** + * CSS identity values for properties whose "rest" state isn't 0 — used to + * synthesize the missing endpoint when converting a flat tween to keyframes. + */ +const CSS_IDENTITY: Record = { + opacity: 1, + autoAlpha: 1, + scale: 1, + scaleX: 1, + scaleY: 1, +}; + +function cssIdentityValue(prop: string): number { + return CSS_IDENTITY[prop] ?? 0; +} + +/** Build the identity-endpoint map for a flat tween's properties. */ +function buildIdentityMap(props: Record): Record { + const identity: Record = {}; + for (const [key, val] of Object.entries(props)) { + if (val != null) identity[key] = typeof val === "number" ? cssIdentityValue(key) : val; + } + return identity; +} + +/** + * Resolve the 0% (from) and 100% (to) property maps for a tween being + * converted to percentage keyframes. + * + * @param resolvedFromValues — Despite the "from" in the name (historical), these + * are runtime-captured DOM values that override the conversion endpoint: + * - For to(): overrides fromProps (the 0% state / where the element is now). + * - For from(): overrides toProps (the 100% state / where the element rests). + * - For fromTo(): merges into toProps (the 100% endpoint the user is editing). + */ +export function resolveConversionProps( + anim: GsapAnimation, + resolvedFromValues?: Record, +): { fromProps: Record; toProps: Record } { + if (anim.method === "set") { + // A static hold becomes a keyframed `to` whose 0% and 100% both start at the + // set's value — the visual is unchanged until the user edits a keyframe to + // animate it. (The caller flips the call from `set` to `to` + adds a duration.) + return { fromProps: { ...anim.properties }, toProps: { ...anim.properties } }; + } + if (anim.method === "to") { + const identity = buildIdentityMap(anim.properties); + const fromProps = resolvedFromValues ? { ...identity, ...resolvedFromValues } : identity; + return { fromProps, toProps: { ...anim.properties } }; + } + if (anim.method === "from") { + const identity = buildIdentityMap(anim.properties); + const toProps = resolvedFromValues ? { ...identity, ...resolvedFromValues } : identity; + return { fromProps: { ...anim.properties }, toProps }; + } + // fromTo(fromVars, toVars): anim.fromProperties = fromVars (0% state), + // anim.properties = toVars (100% state). resolvedFromValues contains the + // current DOM position from a drag — it represents the NEW destination, so + // it merges into toProps (the 100% endpoint the user is editing), NOT into + // fromProps. This is intentional and not inverted. + const toProps = resolvedFromValues + ? { ...anim.properties, ...resolvedFromValues } + : { ...anim.properties }; + return { fromProps: { ...(anim.fromProperties ?? {}) }, toProps }; +} + +// ── Arc path serialization helpers (shared by recast + acorn writers) ───────── + +function numericXY(props: Record): { x: number; y: number } | null { + const vx = props.x; + const vy = props.y; + return typeof vx === "number" && typeof vy === "number" ? { x: vx, y: vy } : null; +} + +export function extractArcWaypoints(anim: GsapAnimation): Array<{ x: number; y: number }> { + const keyframeWps = (anim.keyframes?.keyframes ?? []) + .map((kf) => numericXY(kf.properties)) + .filter((pt): pt is { x: number; y: number } => pt !== null); + if (keyframeWps.length >= 2) return keyframeWps; + const propX = anim.properties.x; + const propY = anim.properties.y; + if (typeof propX !== "number" && typeof propY !== "number") return keyframeWps; + const destX = typeof propX === "number" ? propX : 0; + const destY = typeof propY === "number" ? propY : 0; + return [ + { x: 0, y: 0 }, + { x: destX, y: destY }, + ]; +} + +function autoRotateSuffix(autoRotate: boolean | number): string { + if (autoRotate === true) return ", autoRotate: true"; + if (typeof autoRotate === "number") return `, autoRotate: ${autoRotate}`; + return ""; +} + +function cubicControlPoints( + seg: ArcPathSegment, + wp: { x: number; y: number }, + nextWp: { x: number; y: number }, +): string[] { + if (seg.cp1 && seg.cp2) { + return [`{x: ${seg.cp1.x}, y: ${seg.cp1.y}}`, `{x: ${seg.cp2.x}, y: ${seg.cp2.y}}`]; + } + const dx = nextWp.x - wp.x; + const dy = nextWp.y - wp.y; + const c = seg.curviness ?? 1; + return [ + `{x: ${wp.x + dx * 0.33}, y: ${wp.y + dy * 0.33 - c * Math.abs(dx) * 0.25}}`, + `{x: ${wp.x + dx * 0.66}, y: ${wp.y + dy * 0.66 - c * Math.abs(dx) * 0.25}}`, + ]; +} + +function buildCubicPathEntries( + waypoints: Array<{ x: number; y: number }>, + segments: ArcPathSegment[], +): string[] { + const first = waypoints[0]; + if (!first) return []; + const entries = [`{x: ${first.x}, y: ${first.y}}`]; + for (let i = 0; i < segments.length; i++) { + const seg = segments[i]; + const wp = waypoints[i]; + const nextWp = waypoints[i + 1]; + if (!seg || !wp || !nextWp) continue; + entries.push(...cubicControlPoints(seg, wp, nextWp)); + entries.push(`{x: ${nextWp.x}, y: ${nextWp.y}}`); + } + return entries; +} + +export function buildMotionPathObjectCode(config: { + waypoints: Array<{ x: number; y: number }>; + segments: ArcPathSegment[]; + autoRotate: boolean | number; +}): string { + const { waypoints, segments, autoRotate } = config; + const arSuffix = autoRotateSuffix(autoRotate); + // GSAP's simple `path` array supports only ONE scalar `curviness` for the whole + // path, so per-segment curviness can only be expressed in the cubic form (each + // segment's curviness baked into its control points). Emit cubic when segments + // carry explicit control points OR when their curviness values differ — the + // simple branch would otherwise serialize only segments[0].curviness and drop + // every other segment's curve. + const hasExplicitCp = segments.some((s) => s.cp1 && s.cp2); + const curvinessVaries = segments.some( + (s) => (s.curviness ?? 1) !== (segments[0]?.curviness ?? 1), + ); + if ((hasExplicitCp || curvinessVaries) && waypoints.length >= 2) { + const pathStr = buildCubicPathEntries(waypoints, segments).join(", "); + return `{ path: [${pathStr}], type: "cubic"${arSuffix} }`; + } + const pathEntries = waypoints.map((wp) => `{x: ${wp.x}, y: ${wp.y}}`); + const curviness = segments[0]?.curviness ?? 1; + const curvPart = curviness !== 1 ? `, curviness: ${curviness}` : ""; + return `{ path: [${pathEntries.join(", ")}]${curvPart}${arSuffix} }`; +} diff --git a/packages/core/src/parsers/gsapUnroll.test.ts b/packages/parsers/src/gsapUnroll.test.ts similarity index 100% rename from packages/core/src/parsers/gsapUnroll.test.ts rename to packages/parsers/src/gsapUnroll.test.ts diff --git a/packages/parsers/src/gsapUnroll.ts b/packages/parsers/src/gsapUnroll.ts new file mode 100644 index 0000000000..5506e757c2 --- /dev/null +++ b/packages/parsers/src/gsapUnroll.ts @@ -0,0 +1,143 @@ +/** + * Unroll computed GSAP timelines (helpers / bounded loops) into explicit literal + * tweens — the source-rewrite behind the Studio "Unroll to edit" action. + * + * Strategy: the read parser already resolves each computed tween (positions, + * motionPath arcs, keyframes, provenance). We serialize those resolved + * animations back to literal `tl.*` statements and surgically replace the + * top-level helper-call / loop statements that produced them (and drop the now + * dead helper declarations) via magic-string, leaving the rest of the source — + * literal tweens, comments, formatting — untouched. The result is a visual + * no-op: re-parsing it yields the same animations, now all literal. + * + * Scope: top-level helper calls and loops (the common authoring shape). Tweens + * whose origin can't be mapped to a top-level statement (e.g. helpers nested + * inside other helpers) are left as-is rather than guessed at. + */ +import * as acorn from "acorn"; +import MagicString from "magic-string"; +import type { GsapAnimation } from "./gsapSerialize.js"; +import { serializeValue as valueToCode, safeJsKey as safeKey } from "./gsapSerialize.js"; +import { parseGsapScriptAcorn } from "./gsapParserAcorn.js"; + +// acorn nodes are structurally untyped here. +type Node = any; + +function propEntries(props: Record): string[] { + return Object.entries(props).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`); +} + +function motionPathEntry(anim: GsapAnimation): string { + const waypoints = (anim.keyframes?.keyframes ?? []) + .filter((k) => typeof k.properties.x === "number" && typeof k.properties.y === "number") + .map((k) => `{ x: ${valueToCode(k.properties.x!)}, y: ${valueToCode(k.properties.y!)} }`); + const curviness = anim.arcPath?.segments[0]?.curviness ?? 1; + const autoRotate = anim.arcPath?.autoRotate; + const extra = autoRotate ? `, autoRotate: ${valueToCode(autoRotate as number | string)}` : ""; + return `motionPath: { path: [${waypoints.join(", ")}], curviness: ${curviness}${extra} }`; +} + +function keyframesEntry(anim: GsapAnimation): string { + const kfs = (anim.keyframes?.keyframes ?? []).map((k) => { + const body = propEntries(k.properties); + if (k.ease) body.push(`ease: ${valueToCode(k.ease)}`); + return `"${k.percentage}%": { ${body.join(", ")} }`; + }); + if (anim.keyframes?.easeEach) kfs.push(`easeEach: ${valueToCode(anim.keyframes.easeEach)}`); + return `keyframes: { ${kfs.join(", ")} }`; +} + +/** The vars-object entries for a tween: motionPath/keyframes block, props, duration, ease, extras. */ +function buildVarsParts(anim: GsapAnimation): string[] { + const parts: string[] = []; + if (anim.arcPath?.enabled) parts.push(motionPathEntry(anim)); + else if (anim.keyframes) parts.push(keyframesEntry(anim)); + parts.push(...propEntries(anim.properties)); + if (anim.method !== "set" && anim.duration !== undefined) { + parts.push(`duration: ${valueToCode(anim.duration)}`); + } + if (anim.ease) parts.push(`ease: ${valueToCode(anim.ease)}`); + for (const [k, v] of Object.entries(anim.extras ?? {})) { + parts.push(`${safeKey(k)}: ${valueToCode(v as number | string)}`); + } + return parts; +} + +/** Serialize one resolved animation to a literal `tl.*` statement (arc/keyframe-aware). */ +function serializeTweenStatement(timelineVar: string, anim: GsapAnimation): string { + const obj = `{ ${buildVarsParts(anim).join(", ")} }`; + const pos = valueToCode( + anim.resolvedStart ?? (typeof anim.position === "number" ? anim.position : 0), + ); + const sel = valueToCode(anim.targetSelector); + if (anim.method === "fromTo") { + const from = `{ ${propEntries(anim.fromProperties ?? {}).join(", ")} }`; + return `${timelineVar}.fromTo(${sel}, ${from}, ${obj}, ${pos});`; + } + return `${timelineVar}.${anim.method}(${sel}, ${obj}, ${pos});`; +} + +/** A computed animation is one expanded from a helper or loop (not literal/dynamic). */ +function isComputed(anim: GsapAnimation): boolean { + return anim.provenance?.kind === "helper" || anim.provenance?.kind === "loop"; +} + +/** Top-level statements of the parsed program. */ +function topLevelStatements(script: string): Node[] { + return acorn.parse(script, { ecmaVersion: "latest", sourceType: "script" }).body ?? []; +} + +/** The top-level statement whose source span contains [start, end], or null. */ +function enclosingTopLevel(statements: Node[], start: number, end: number): Node | null { + for (const stmt of statements) { + if (stmt.start <= start && stmt.end >= end) return stmt; + } + return null; +} + +function isHelperDeclNamed(stmt: Node, names: Set): boolean { + if (stmt.type === "FunctionDeclaration") return names.has(stmt.id?.name); + if (stmt.type === "VariableDeclaration") { + return (stmt.declarations ?? []).some((d: Node) => names.has(d.id?.name)); + } + return false; +} + +/** + * Rewrite `script` so top-level helper calls / loops that build the timeline + * become explicit literal tweens. Returns the original script unchanged when + * there is nothing statically-resolvable to unroll. + */ +export function unrollComputedTimeline(script: string): string { + const parsed = parseGsapScriptAcorn(script); + const computed = parsed.animations.filter((a) => isComputed(a) && a.provenance?.sourceRange); + if (computed.length === 0) return script; + + const statements = topLevelStatements(script); + + // Group computed animations by the top-level statement that produced them, + // preserving source order within each group. + const byStatement = new Map(); + const helperNames = new Set(); + for (const anim of computed) { + if (anim.provenance?.fn) helperNames.add(anim.provenance.fn); + const [s, e] = anim.provenance!.sourceRange!; + const stmt = enclosingTopLevel(statements, s, e); + if (!stmt) continue; // nested origin — leave it; can't map to a top-level edit + const list = byStatement.get(stmt) ?? []; + list.push(anim); + byStatement.set(stmt, list); + } + if (byStatement.size === 0) return script; + + const ms = new MagicString(script); + for (const [stmt, anims] of byStatement) { + const literals = anims.map((a) => serializeTweenStatement(parsed.timelineVar, a)).join("\n"); + ms.overwrite(stmt.start, stmt.end, literals); + } + // Drop the now-dead helper declarations. + for (const stmt of statements) { + if (isHelperDeclNamed(stmt, helperNames)) ms.remove(stmt.start, stmt.end); + } + return ms.toString(); +} diff --git a/packages/core/src/parsers/gsapWriter.acorn.test.ts b/packages/parsers/src/gsapWriter.acorn.test.ts similarity index 100% rename from packages/core/src/parsers/gsapWriter.acorn.test.ts rename to packages/parsers/src/gsapWriter.acorn.test.ts diff --git a/packages/core/src/parsers/gsapWriter.parity.test.ts b/packages/parsers/src/gsapWriter.parity.test.ts similarity index 100% rename from packages/core/src/parsers/gsapWriter.parity.test.ts rename to packages/parsers/src/gsapWriter.parity.test.ts diff --git a/packages/core/src/parsers/gsapWriter.reviewFixes.test.ts b/packages/parsers/src/gsapWriter.reviewFixes.test.ts similarity index 100% rename from packages/core/src/parsers/gsapWriter.reviewFixes.test.ts rename to packages/parsers/src/gsapWriter.reviewFixes.test.ts diff --git a/packages/parsers/src/gsapWriterAcorn.ts b/packages/parsers/src/gsapWriterAcorn.ts new file mode 100644 index 0000000000..3d9bf70027 --- /dev/null +++ b/packages/parsers/src/gsapWriterAcorn.ts @@ -0,0 +1,2375 @@ +// fallow-ignore-file code-duplication +/** + * Browser-safe GSAP write path — magic-string offset-splice. + * + * T6c: edits GSAP scripts by overwriting/removing byte ranges in the original + * source. Every byte outside the edited span is preserved verbatim — no + * pretty-printer churn. Consumes ParsedGsapAcornForWrite from gsapParserAcorn.ts. + */ +import MagicString from "magic-string"; +import type { + GsapAnimation, + GsapPercentageKeyframe, + ArcPathConfig, + ArcPathSegment, +} from "./gsapSerialize.js"; +import { + resolveConversionProps, + extractArcWaypoints, + buildMotionPathObjectCode, +} from "./gsapSerialize.js"; +import { + parseGsapScriptAcornForWrite, + type ParsedGsapAcornForWrite, + type TweenCallInfo, +} from "./gsapParserAcorn.js"; +import { classifyPropertyGroup } from "./gsapConstants.js"; +import type { PropertyGroupName } from "./gsapConstants.js"; +import type { SplitAnimationsOptions, SplitAnimationsResult } from "./gsapSerialize.js"; +import * as acornWalk from "acorn-walk"; + +// acorn ESTree nodes are structurally untyped here; mirror gsapParserAcorn.ts / +// gsapInline.ts rather than re-deriving the full ESTree union for every access. +type Node = any; + +// ── Code generation helpers ────────────────────────────────────────────────── + +// Local serializer for the tween-statement path, which may carry boolean/object +// extras (stagger config). serializeValue stringifies objects to "[object +// Object]", so keep this richer JSON fallback for that path. Keyframe values are +// always number|string and use the shared serializeValue (recast parity). +function valueToCode(value: unknown): string { + if (typeof value === "string" && value.startsWith("__raw:")) return value.slice(6); + if (typeof value === "string") return JSON.stringify(value); + if (typeof value === "number") return Number.isNaN(value) ? "0" : String(value); + if (typeof value === "boolean") return String(value); + return JSON.stringify(value); +} + +function safeKey(key: string): string { + return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key) ? key : JSON.stringify(key); +} + +// fallow-ignore-next-line complexity +function buildTweenStatementCode(timelineVar: string, anim: Omit): string { + const selector = JSON.stringify(anim.targetSelector); + const props: Record = { ...anim.properties }; + if (anim.method !== "set" && anim.duration !== undefined) props.duration = anim.duration; + if (anim.ease) props.ease = anim.ease; + const entries = Object.entries(props).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`); + if (anim.extras) { + for (const [k, v] of Object.entries(anim.extras)) { + entries.push(`${safeKey(k)}: ${valueToCode(v)}`); + } + } + const objCode = `{ ${entries.join(", ")} }`; + const posCode = valueToCode( + typeof anim.position === "number" ? anim.position : (anim.position ?? 0), + ); + if (anim.method === "fromTo") { + const fromEntries = Object.entries(anim.fromProperties ?? {}).map( + ([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`, + ); + return `${timelineVar}.fromTo(${selector}, { ${fromEntries.join(", ")} }, ${objCode}, ${posCode});`; + } + // A base `gsap.set` is off the timeline: no timeline var, no position arg. + if (anim.method === "set" && anim.global) { + return `gsap.set(${selector}, ${objCode});`; + } + return `${timelineVar}.${anim.method}(${selector}, ${objCode}, ${posCode});`; +} + +// ── AST node helpers ───────────────────────────────────────────────────────── + +function isObjectProperty(prop: Node): boolean { + return prop?.type === "ObjectProperty" || prop?.type === "Property"; +} + +function propKeyName(prop: Node): string | undefined { + return prop?.key?.name ?? prop?.key?.value; +} + +function findPropertyNode(varsArgNode: Node, key: string): Node | undefined { + if (varsArgNode?.type !== "ObjectExpression") return undefined; + for (const prop of varsArgNode.properties ?? []) { + if (!isObjectProperty(prop)) continue; + if (propKeyName(prop) === key) return prop; + } + return undefined; +} + +/** The `keyframes` property's ObjectExpression value, or null when not a keyframe tween. */ +function keyframesObjectNode(varsNode: Node): Node | null { + const kfProp = findPropertyNode(varsNode, "keyframes"); + return kfProp?.value?.type === "ObjectExpression" ? kfProp.value : null; +} + +function findEnclosingExpressionStatement(ancestors: Node[]): Node | null { + for (let i = ancestors.length - 2; i >= 0; i--) { + if (ancestors[i]?.type === "ExpressionStatement") return ancestors[i]; + } + return null; +} + +/** Find the VariableDeclaration statement for `tl = gsap.timeline(...)`. */ +function findTimelineDeclarationStatement(ast: Node, timelineVar: string): Node | null { + let found: Node = null; + acornWalk.simple(ast, { + // fallow-ignore-next-line complexity + VariableDeclaration(node: Node) { + if (found) return; + for (const decl of node.declarations ?? []) { + if ( + decl.id?.name === timelineVar && + decl.init?.type === "CallExpression" && + decl.init.callee?.type === "MemberExpression" && + decl.init.callee.object?.name === "gsap" && + decl.init.callee.property?.name === "timeline" + ) { + found = node; + } + } + }, + }); + return found; +} + +// ── Property splice helpers ─────────────────────────────────────────────────── + +/** + * Remove a property from a properties array, handling its comma. + * `editableProps` must be the isObjectProperty-filtered subset in source order. + */ +function removeProp(ms: MagicString, propNode: Node, editableProps: Node[]): void { + const idx = editableProps.indexOf(propNode); + if (idx === -1) return; + if (editableProps.length === 1) { + ms.remove(propNode.start, propNode.end); + } else if (idx === 0) { + // First prop: remove from its start to next prop start (drops trailing ", ") + ms.remove(editableProps[0].start, editableProps[1].start); + } else { + // Non-first: remove from prev prop end to this prop end (drops leading ", ") + ms.remove(editableProps[idx - 1].end, propNode.end); + } +} + +/** Serialize a vars record to an object-literal source: `{ k: v, ... }`. */ +function buildVarsObjectCode(record: Record): string { + const entries = Object.entries(record).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`); + return entries.length > 0 ? `{ ${entries.join(", ")} }` : "{}"; +} + +/** Overwrite a tween call's vars ObjectExpression with freshly-built source. */ +function overwriteVarsArg(ms: MagicString, call: TweenCallInfo, objCode: string): void { + if (!call.varsArg) return; + ms.overwrite(call.varsArg.start, call.varsArg.end, objCode); +} + +/** + * 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: Node, key: string, value: unknown): void { + if (objNode?.type !== "ObjectExpression") return; + const existing = findPropertyNode(objNode, key); + if (existing) { + ms.overwrite(existing.value.start, existing.value.end, valueToCode(value)); + } else { + const sep = objNode.properties.length > 0 ? ", " : ""; + ms.appendLeft(objNode.end - 1, `${sep}${safeKey(key)}: ${valueToCode(value)}`); + } +} + +/** + * Vars keys that are NOT editable transform/style props: builtins + * (duration/ease/delay), dropped callbacks, and extras (stagger/yoyo/repeat/…). + * The exact union of recast's BUILTIN_VAR_KEYS + DROPPED_VAR_KEYS + EXTRAS_KEYS, + * so both writers classify vars keys identically. (Distinct from the keyframe- + * conversion NON_EDITABLE_VAR_KEYS below, which intentionally omits `ease` + * because that path re-emits ease separately.) + */ +const NON_EDITABLE_PROP_KEYS = new Set([ + "duration", + "ease", + "delay", + "onComplete", + "onStart", + "onUpdate", + "onRepeat", + "stagger", + "yoyo", + "repeat", + "repeatDelay", + "snap", + "overwrite", + "immediateRender", +]); + +/** + * Editable transform/style key test: anything NOT a builtin, dropped callback, or + * extras key. Mirrors recast's isEditablePropertyKey so both writers classify + * vars keys identically. + */ +function isEditableVarKey(key: string): boolean { + return !NON_EDITABLE_PROP_KEYS.has(key); +} + +/** + * Collect verbatim `key: value` entries to PRESERVE from a vars/keyframe + * ObjectExpression: every property whose key `drop` does not reject, sliced from + * source — except keys present in `overrides`, whose value is replaced. Returns + * the entries plus the set of keys it kept, so callers can append new keys. + */ +function preservedEntries( + objNode: Node, + source: string, + drop: (key: string) => boolean, + overrides: Record, +): { entries: string[]; keys: Set } { + const entries: string[] = []; + const keys = new Set(); + for (const prop of objNode.properties ?? []) { + if (!isObjectProperty(prop)) continue; + const key = propKeyName(prop); + if (typeof key !== "string" || drop(key)) continue; + keys.add(key); + const code = + key in overrides + ? valueToCode(overrides[key]) + : source.slice(prop.value.start, prop.value.end); + entries.push(`${safeKey(key)}: ${code}`); + } + return { entries, keys }; +} + +/** + * Replace the editable-property keys on a vars ObjectExpression with exactly + * `newProps`, leaving non-editable keys (duration/ease/stagger/callbacks/…) + * untouched unless overridden in `nonEditableOverrides`. Mirrors recast's + * reconcileEditableProperties: editable keys absent from `newProps` are DROPPED, + * not merged. Rebuilt in a single ms.overwrite so the splice can never overlap a + * sibling edit — non-editable updates that also target this node (duration/ease/ + * extras) are folded into the same rebuild rather than spliced separately. + */ +function reconcileEditableProps( + ms: MagicString, + objNode: Node, + source: string, + newProps: Record, + nonEditableOverrides?: Record, +): void { + if (objNode?.type !== "ObjectExpression") return; + const overrides = nonEditableOverrides ?? {}; + const { entries, keys } = preservedEntries(objNode, source, isEditableVarKey, overrides); + for (const [key, value] of Object.entries(overrides)) { + if (!keys.has(key)) entries.push(`${safeKey(key)}: ${valueToCode(value)}`); + } + for (const [key, value] of Object.entries(newProps)) { + entries.push(`${safeKey(key)}: ${valueToCode(value)}`); + } + ms.overwrite(objNode.start, objNode.end, `{ ${entries.join(", ")} }`); +} + +// ── Insertion helpers ───────────────────────────────────────────────────────── + +/** Traverse callee.object chain to check if a call ultimately roots at timelineVar. */ +function isTimelineRooted(node: Node, 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 { + const lastLocated = parsed.located[parsed.located.length - 1]; + if (lastLocated) { + const lastCall = lastLocated.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 +export function updateAnimationInScript( + script: string, + animationId: string, + updates: Partial & { easeEach?: string; resetKeyframeEases?: boolean }, +): string { + if (!Object.keys(updates).length) return script; + const parsed = parseGsapScriptAcornForWrite(script); + if (!parsed) return script; + const target = parsed.located.find((l) => l.id === animationId); + if (!target) return script; + + const ms = new MagicString(script); + const { call }: { call: TweenCallInfo } = target; + + // When `properties` is present we REPLACE the editable set (recast parity: + // editable keys absent from the update are dropped). Fold any concurrent + // non-editable updates (duration/ease/extras) into the single varsArg rebuild + // so their splices can't overlap the rebuild's overwrite of the whole node. + if (updates.properties) { + const overrides: Record = {}; + if (updates.duration !== undefined) overrides.duration = updates.duration; + if (updates.ease !== undefined) overrides.ease = updates.ease; + if (updates.extras) Object.assign(overrides, updates.extras); + reconcileEditableProps(ms, call.varsArg, script, updates.properties, overrides); + } else { + if (updates.duration !== undefined) { + upsertProp(ms, call.varsArg, "duration", updates.duration); + } + const easeValue = updates.easeEach ?? updates.ease; + if (easeValue !== undefined) { + const kfNode = keyframesObjectNode(call.varsArg); + if (kfNode) { + upsertProp(ms, kfNode, "easeEach", easeValue); + // "Apply to all segments": drop every per-keyframe `ease` override so the + // single easeEach governs all segments uniformly (AE select-all + F9). + if (updates.resetKeyframeEases) { + for (const kfEntry of kfNode.properties ?? []) { + if (!isObjectProperty(kfEntry)) continue; + const val = kfEntry.value; + if (val?.type !== "ObjectExpression") continue; + const easeNode = findPropertyNode(val, "ease"); + if (easeNode) removeProp(ms, easeNode, val.properties); + } + } + } else { + upsertProp(ms, call.varsArg, "ease", easeValue); + } + } + if (updates.extras) { + for (const [key, value] of Object.entries(updates.extras)) { + upsertProp(ms, call.varsArg, key, value); + } + } + } + + if (updates.fromProperties && call.method === "fromTo" && call.fromArg) { + // fromTo's from-vars carry only editable props — REPLACE them too (recast + // parity). fromArg is a distinct node from varsArg, so this rebuild never + // overlaps the varsArg edits above. + reconcileEditableProps(ms, call.fromArg, script, updates.fromProperties); + } + + if (updates.position !== undefined) { + overwritePosition(ms, call, updates.position); + } + + return ms.toString(); +} + +/** + * Overwrite a tween call's numeric position argument (the positionArg the parser + * located: 3rd arg for fromTo, else 2nd), or append one when the call has no + * explicit position. Shared by updateAnimationInScript and the + * shift/scalePositionsInScript timeline ops. + */ +function overwritePosition(ms: MagicString, call: TweenCallInfo, position: number | string): void { + if (call.positionArg) { + ms.overwrite(call.positionArg.start, call.positionArg.end, valueToCode(position)); + } else { + ms.appendLeft(call.node.end - 1, `, ${valueToCode(position)}`); + } +} + +/** + * Shift every tween targeting `targetSelector` by `delta` seconds (clamped ≥0), + * rewriting each call's position argument. Mirrors recast's shiftPositionsInScript + * (used by timeline clip-move to keep GSAP positions in sync with the clip start). + */ +export function shiftPositionsInScript( + script: string, + targetSelector: string, + delta: number, +): string { + const parsed = parseGsapScriptAcornForWrite(script); + if (!parsed) return script; + const ms = new MagicString(script); + let changed = false; + for (const entry of parsed.located) { + if (entry.animation.targetSelector !== targetSelector) continue; + if (typeof entry.animation.position !== "number") continue; + const newPos = Math.max(0, Math.round((entry.animation.position + delta) * 1000) / 1000); + overwritePosition(ms, entry.call, newPos); + changed = true; + } + return changed ? ms.toString() : script; +} + +/** + * Linearly remap every tween targeting `targetSelector` from the old clip + * [oldStart, oldDuration] onto the new [newStart, newDuration] (position and, + * when present, duration scaled by the duration ratio). Mirrors recast's + * scalePositionsInScript (used by timeline clip-resize). + */ +export function scalePositionsInScript( + script: string, + targetSelector: string, + oldStart: number, + oldDuration: number, + newStart: number, + newDuration: number, +): string { + if (oldDuration <= 0 || newDuration <= 0) return script; + const ratio = newDuration / oldDuration; + const parsed = parseGsapScriptAcornForWrite(script); + if (!parsed) return script; + const ms = new MagicString(script); + let changed = false; + for (const entry of parsed.located) { + if (entry.animation.targetSelector !== targetSelector) continue; + if (typeof entry.animation.position !== "number") continue; + const newPos = Math.max( + 0, + Math.round((newStart + (entry.animation.position - oldStart) * ratio) * 1000) / 1000, + ); + overwritePosition(ms, entry.call, newPos); + if (typeof entry.animation.duration === "number" && entry.animation.duration > 0) { + const newDur = Math.max(0.001, Math.round(entry.animation.duration * ratio * 1000) / 1000); + upsertProp(ms, entry.call.varsArg, "duration", newDur); + } + changed = true; + } + return changed ? ms.toString() : script; +} + +export function addAnimationToScript( + script: string, + animation: Omit, +): { script: string; id: string } { + 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); + ms.appendLeft(insertionPoint, "\n" + statementCode); + + const result = ms.toString(); + const reParsed = parseGsapScriptAcornForWrite(result); + const newId = reParsed?.located[reParsed.located.length - 1]?.id ?? ""; + return { script: result, id: newId }; +} + +export function removeAnimationFromScript(script: string, animationId: string): string { + const parsed = parseGsapScriptAcornForWrite(script); + if (!parsed) return script; + const target = parsed.located.find((l) => l.id === animationId); + if (!target) return script; + + const ms = new MagicString(script); + const N = target.call.node; + const exprStmt = findEnclosingExpressionStatement(target.call.ancestors); + + if (N.callee?.object?.type !== "CallExpression" && exprStmt?.expression === N) { + // Standalone `tl.method(...)` — remove the whole ExpressionStatement + const end = + exprStmt.end < script.length && script[exprStmt.end] === "\n" + ? exprStmt.end + 1 + : exprStmt.end; + ms.remove(exprStmt.start, end); + } else { + // Chain link — splice out `.method(args)` from N.callee.object.end to N.end + ms.remove(N.callee.object.end, N.end); + } + + return ms.toString(); +} + +// ── Flat-tween → keyframes conversion ────────────────────────────────────────── +// +// Mirror recast's convertToKeyframesInScript: when the first keyframe op lands +// on a flat to()/from()/fromTo() tween, rewrite its vars object to +// `{ keyframes: { "0%": {from}, "100%": {to} }, , +// ease: "none"? }` and convert from()/fromTo() to to(). We rebuild the whole +// vars ObjectExpression in one ms.overwrite (single-edit-per-node), so the next +// keyframe-add re-parses cleanly. + +// Identity value for an editable transform/style prop (recast's CSS_IDENTITY). +const CSS_IDENTITY: Record = { + opacity: 1, + autoAlpha: 1, + scale: 1, + scaleX: 1, + scaleY: 1, +}; + +function cssIdentityValue(prop: string): number { + return CSS_IDENTITY[prop] ?? 0; +} + +// Keys NOT in the editable set — preserved verbatim on the converted vars object +// (matches the parser's classification: builtin/dropped/extras keys). +const NON_EDITABLE_VAR_KEYS = new Set([ + "duration", + "delay", + "onComplete", + "onStart", + "onUpdate", + "onRepeat", + "stagger", + "yoyo", + "repeat", + "repeatDelay", + "snap", + "overwrite", + "immediateRender", +]); + +/** The CSS-identity counterpart of a props record (numbers → identity value). */ +function identityProps( + properties: Record, +): Record { + const identity: Record = {}; + for (const [k, v] of Object.entries(properties)) { + if (v != null) identity[k] = typeof v === "number" ? cssIdentityValue(k) : v; + } + return identity; +} + +/** Resolve the 0%/100% endpoint records for a tween being converted. */ +function conversionEndpoints(animation: GsapAnimation): { + fromProps: Record; + toProps: Record; +} { + if (animation.method === "from") { + return { fromProps: { ...animation.properties }, toProps: identityProps(animation.properties) }; + } + if (animation.method === "fromTo") { + return { + fromProps: { ...(animation.fromProperties ?? {}) }, + toProps: { ...animation.properties }, + }; + } + // to(): 0% is the CSS identity state, 100% is the authored props. + return { fromProps: identityProps(animation.properties), toProps: { ...animation.properties } }; +} + +/** Collect preserved (non-editable) `key: value` entries from the original vars node. */ +function preservedVarsEntries(varsNode: Node, source: string): string[] { + const entries: string[] = []; + if (varsNode?.type !== "ObjectExpression") return entries; + for (const prop of varsNode.properties ?? []) { + if (!isObjectProperty(prop)) continue; + const key = propKeyName(prop); + if (typeof key !== "string" || !NON_EDITABLE_VAR_KEYS.has(key)) continue; + entries.push(`${safeKey(key)}: ${source.slice(prop.value.start, prop.value.end)}`); + } + return entries; +} + +/** Build the rebuilt vars-object code for a converted flat tween. */ +function buildConvertedVarsCode(animation: GsapAnimation, varsNode: Node, source: string): string { + const { fromProps, toProps } = conversionEndpoints(animation); + const easeEach = animation.ease; + const easeEachEntry = easeEach ? `, easeEach: ${JSON.stringify(easeEach)}` : ""; + const kfCode = `{ "0%": ${recordToCode(fromProps)}, "100%": ${recordToCode(toProps)}${easeEachEntry} }`; + const entries = [`keyframes: ${kfCode}`, ...preservedVarsEntries(varsNode, source)]; + if (easeEach) entries.push(`ease: "none"`); + return `{ ${entries.join(", ")} }`; +} + +/** Rename a from()/fromTo() call to to(), dropping fromTo's leading from-vars arg. */ +function convertMethodToTo( + ms: MagicString, + animation: GsapAnimation, + call: Node, + varsNode: Node, +): void { + if (animation.method !== "from" && animation.method !== "fromTo") return; + const calleeProp = call.node.callee?.property; + if (calleeProp) ms.overwrite(calleeProp.start, calleeProp.end, "to"); + // Remove the from-vars arg and its trailing separator up to the to-vars arg. + if (animation.method === "fromTo" && call.fromArg) ms.remove(call.fromArg.start, varsNode.start); +} + +function convertFlatTweenToKeyframes(script: string, target: Node): string { + const animation: GsapAnimation = target.animation; + if (animation.keyframes || animation.method === "set") return script; + const call = target.call; + const varsNode = call.varsArg; + if (varsNode?.type !== "ObjectExpression") return script; + + const ms = new MagicString(script); + ms.overwrite(varsNode.start, varsNode.end, buildConvertedVarsCode(animation, varsNode, script)); + convertMethodToTo(ms, animation, call, varsNode); + return ms.toString(); +} + +// ── Keyframe write ops ──────────────────────────────────────────────────────── +// +// Design: mirror the recast writer's rebuild-the-node model. The recast writer +// mutates AST nodes in place and re-prints, so it never has an offset-overlap +// problem. Here we instead compute the FINAL property record for every keyframe +// value node that must change (the target merge, `_auto` endpoint sync, and +// backfilled siblings) against the ORIGINAL parsed AST, then emit exactly ONE +// `ms.overwrite(valueNode.start, valueNode.end, code)` per changed node (and a +// single insert for a brand-new key). No node is ever both overwritten and +// appended into, so the splices can never overlap. + +const PERCENTAGE_KEY_RE = /^(\d+(?:\.\d+)?)%$/; + +// Matches recast's PCT_TOLERANCE: percentages within 2 of an existing key are +// treated as the same keyframe (merge), not a new insert. +const PCT_TOLERANCE = 2; + +function percentageFromKey(key: string): number { + const m = PERCENTAGE_KEY_RE.exec(key); + return m ? Number.parseFloat(m[1] ?? "0") : Number.NaN; +} + +/** Serialize a final keyframe property record (number|string values) to code. */ +function recordToCode(record: Record): string { + const entries = Object.entries(record).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`); + return `{ ${entries.join(", ")} }`; +} + +/** Percentage-keyed property nodes of a keyframes ObjectExpression, in source order. */ +function percentagePropsOf(kfNode: Node): Node[] { + return (kfNode.properties ?? []).filter((p: Node) => { + if (!isObjectProperty(p)) return false; + const key = propKeyName(p); + return typeof key === "string" && PERCENTAGE_KEY_RE.test(key); + }); +} + +const LITERAL_NODE_TYPES = new Set(["Literal", "NumericLiteral", "StringLiteral"]); + +/** Read one value node: a number/string literal, a negative number, or raw source. */ +// fallow-ignore-next-line complexity +function readValueNode(v: Node, source: string): number | string { + if ( + LITERAL_NODE_TYPES.has(v?.type) && + (typeof v.value === "number" || typeof v.value === "string") + ) { + return v.value; + } + if ( + v?.type === "UnaryExpression" && + v.operator === "-" && + typeof v.argument?.value === "number" + ) { + return -v.argument.value; + } + return `__raw:${source.slice(v.start, v.end)}`; +} + +/** + * Read a keyframe value ObjectExpression into a record, mirroring the parser's + * `objectExpressionToRecord`: literals resolve to their value; anything else is + * preserved as `__raw:` so serializeValue round-trips it verbatim. + * Keyframe values are literals in practice, so the raw fallback is rarely hit. + */ +function valueNodeToRecord(valueNode: Node, source: string): Record { + const record: Record = {}; + if (valueNode?.type !== "ObjectExpression") return record; + for (const prop of valueNode.properties ?? []) { + if (!isObjectProperty(prop)) continue; + const key = propKeyName(prop); + if (typeof key !== "string") continue; + record[key] = readValueNode(prop.value, source); + } + return record; +} + +/** True when a keyframe value record carries the synthetic `_auto` marker. */ +function recordHasAuto(record: Record): boolean { + return "_auto" in record; +} + +/** + * Compute `_auto` endpoint overwrites: when the new keyframe is the immediate + * neighbor of an `_auto` 0% or 100% endpoint, that endpoint is rewritten to + * `{ ...newProps, _auto: 1 }`. Only fires for interior keyframes. Returns the + * percentage→overwrite map so the caller can fold these into the per-node final + * records (never a separate splice). + */ +function autoEndpointOverwrites( + kfNode: Node, + source: string, + percentage: number, + properties: Record, +): Map> { + const result = new Map>(); + if (percentage <= 0 || percentage >= 100) return result; + const pctProps = percentagePropsOf(kfNode); + const allPcts = pctProps + .map((p: Node) => percentageFromKey(propKeyName(p) ?? "")) + .filter((n: number) => !Number.isNaN(n) && n !== percentage) + .sort((a: number, b: number) => a - b); + const leftNeighbor = allPcts.filter((p: number) => p < percentage).pop(); + const rightNeighbor = allPcts.find((p: number) => p > percentage); + for (const endPct of [0, 100]) { + const isNeighbor = endPct === 0 ? leftNeighbor === 0 : rightNeighbor === 100; + if (!isNeighbor) continue; + const endProp = pctProps.find((p: Node) => percentageFromKey(propKeyName(p) ?? "") === endPct); + if (!endProp) continue; + const rec = valueNodeToRecord(endProp.value, source); + if (!recordHasAuto(rec)) continue; + result.set(endProp, { ...properties, _auto: 1 }); + } + return result; +} + +function findKfPropByPct(kfNode: Node, percentage: number): { prop: Node; idx: number } | null { + // Match the CLOSEST keyframe within tolerance, not the first one within range. + // Keyframes at e.g. 0/49/50/100 are all valid (the SDK dedups to a unique + // match at TOLERANCE=0.001 upstream); picking the first-within-PCT_TOLERANCE=2 + // would hit 49% when the caller meant 50%. Tie-break on the earliest index so + // the choice stays deterministic. + const props = kfNode.properties ?? []; + let best: { prop: Node; idx: number } | null = null; + let bestDist = Number.POSITIVE_INFINITY; + for (let i = 0; i < props.length; i++) { + const prop = props[i]; + if (!isObjectProperty(prop)) continue; + const key = propKeyName(prop); + if (typeof key !== "string") continue; + const dist = Math.abs(percentageFromKey(key) - percentage); + if (dist <= PCT_TOLERANCE && dist < bestDist) { + best = { prop, idx: i }; + bestDist = dist; + } + } + return best; +} + +export function updateKeyframeInScript( + script: string, + animationId: string, + percentage: number, + properties: Record, + ease?: string, +): string { + const parsed = parseGsapScriptAcornForWrite(script); + if (!parsed) return script; + const target = parsed.located.find((l) => l.id === animationId); + if (!target) return script; + + const kfPropNode = findPropertyNode(target.call.varsArg, "keyframes"); + if (!kfPropNode) return script; + + // Array-form keyframes (`keyframes: [{x,y}, ...]`) carry no explicit percentages + // — GSAP distributes them evenly, and the runtime read assigns even percentages + // (0, 100/(n-1), …). Map the percentage back to an array index and overwrite that + // element in place (preserving the array form). Without this the function bailed + // on the ObjectExpression check, so dragging a motion-path node on an array-form + // tween committed nothing (server no-op). + if (kfPropNode.value?.type === "ArrayExpression") { + return updateArrayKeyframeByPct(script, kfPropNode.value, percentage, properties, ease); + } + if (kfPropNode.value?.type !== "ObjectExpression") return script; + + const match = findKfPropByPct(kfPropNode.value, percentage); + if (!match) return script; + + const record: Record = { ...properties }; + if (ease) record.ease = ease; + const ms = new MagicString(script); + ms.overwrite(match.prop.value.start, match.prop.value.end, recordToCode(record)); + return ms.toString(); +} + +// ponytail: even-spacing index map; if array keyframes ever carry per-element +// `duration`, switch to matching the closest cumulative position. +function updateArrayKeyframeByPct( + script: string, + arrayNode: Node, + percentage: number, + properties: Record, + ease?: string, +): string { + const elements = ((arrayNode.elements ?? []) as Array).filter( + (el): el is Node => !!el && el.type === "ObjectExpression", + ); + const n = elements.length; + if (n === 0) return script; + const idx = n > 1 ? Math.round((percentage / 100) * (n - 1)) : 0; + const el = elements[Math.max(0, Math.min(n - 1, idx))]; + if (!el) return script; + const merged: Record = { + ...valueNodeToRecord(el, script), + ...properties, + }; + if (ease) merged.ease = ease; + const ms = new MagicString(script); + ms.overwrite(el.start, el.end, recordToCode(merged)); + return ms.toString(); +} + +/** + * Build the final property record for the keyframe at `percentage`. If a + * keyframe already exists there, MERGE the new props over the existing record + * (preserve untouched props, preserve `_auto`, preserve the existing per-keyframe + * ease when the op omits one); otherwise it's just the new props. + */ +function buildTargetRecord( + existing: { prop: Node; idx: number } | null, + source: string, + properties: Record, + ease: string | undefined, +): Record { + if (!existing || existing.prop.value?.type !== "ObjectExpression") { + const record: Record = { ...properties }; + if (ease) record.ease = ease; + return record; + } + const existingRecord = valueNodeToRecord(existing.prop.value, source); + const existingEase = typeof existingRecord.ease === "string" ? existingRecord.ease : undefined; + const merged: Record = { ...existingRecord }; + for (const [k, v] of Object.entries(properties)) merged[k] = v; + const finalEase = ease ?? existingEase; + if (finalEase) merged.ease = finalEase; + else delete merged.ease; + return merged; +} + +/** + * Compute the backfilled final record for one sibling keyframe: append any of + * `newPropKeys` it's missing, using the backfill default. Returns null when + * nothing changes (so the caller emits no overwrite for it). + */ +function backfilledSiblingRecord( + valueNode: Node, + source: string, + newPropKeys: string[], + backfillDefaults: Record, +): Record | null { + if (valueNode?.type !== "ObjectExpression") return null; + const record = valueNodeToRecord(valueNode, source); + let changed = false; + for (const pk of newPropKeys) { + const defaultVal = backfillDefaults[pk]; + if (pk in record || defaultVal == null) continue; + record[pk] = defaultVal; + changed = true; + } + return changed ? record : null; +} + +/** A located tween whose varsArg has a static keyframes ObjectExpression, or null. */ +function locateWithKeyframes( + script: string, + animationId: string, +): { script: string; parsed: ParsedGsapAcornForWrite; target: Node; kfNode: Node } | null { + const parsed = parseGsapScriptAcornForWrite(script); + if (!parsed) return null; + // Converting from()/fromTo() to to() rewrites the content-derived id; match + // recast's locateAnimationWithFallback by remapping the method segment. + const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-"); + const target = + parsed.located.find((l) => l.id === animationId) ?? + parsed.located.find((l) => l.id === convertedId); + if (!target) return null; + const kfPropNode = findPropertyNode(target.call.varsArg, "keyframes"); + if (!kfPropNode || kfPropNode.value?.type !== "ObjectExpression") return null; + return { script, parsed, target, kfNode: kfPropNode.value }; +} + +/** Locate a tween's keyframes object, converting a flat tween first if absent. */ +// Array-form keyframes (`keyframes: [{x,y}, …]`) → even-percentage object form +// (`{ "0%": {…}, "33.3%": {…}, … }`). Inserting a keyframe needs percentage keys, +// which an even array can't host. Runtime-identical; mirrors the recast path. +function convertArrayKeyframesToObject(script: string, target: Node): string { + const kfPropNode = findPropertyNode(target.call.varsArg, "keyframes"); + if (!kfPropNode || kfPropNode.value?.type !== "ArrayExpression") return script; + const els = ((kfPropNode.value.elements ?? []) as Array).filter( + (el): el is Node => !!el && el.type === "ObjectExpression", + ); + const n = els.length; + if (n === 0) return script; + const entries = els.map((el, i) => { + const pct = n > 1 ? Math.round((i / (n - 1)) * 1000) / 10 : 0; + return `${JSON.stringify(`${pct}%`)}: ${script.slice(el.start, el.end)}`; + }); + const ms = new MagicString(script); + ms.overwrite(kfPropNode.value.start, kfPropNode.value.end, `{ ${entries.join(", ")} }`); + return ms.toString(); +} + +function ensureKeyframesNode( + script: string, + animationId: string, +): { script: string; parsed: ParsedGsapAcornForWrite; target: Node; kfNode: Node } | null { + const direct = locateWithKeyframes(script, animationId); + if (direct) return direct; + + const parsed = parseGsapScriptAcornForWrite(script); + const target = parsed?.located.find((l) => l.id === animationId); + if (!target) return null; + + // Array-form keyframes → normalize to object form, then re-locate. + const kfProp = findPropertyNode(target.call.varsArg, "keyframes"); + if (kfProp?.value?.type === "ArrayExpression") { + const normalized = convertArrayKeyframesToObject(script, target); + if (normalized !== script) return locateWithKeyframes(normalized, animationId); + return null; + } + + // No static keyframes object — convert the flat tween, then re-locate. + const converted = convertFlatTweenToKeyframes(script, target); + if (converted === script) return null; + return locateWithKeyframes(converted, animationId); +} + +/** + * Compute the sibling keyframe nodes that need a backfilled prop, excluding the + * target keyframe and any node already being overwritten as an `_auto` endpoint. + */ +function collectBackfillOverwrites( + kfNode: Node, + src: string, + properties: Record, + backfillDefaults: Record | undefined, + skip: { existingProp: Node; endpoints: Map }, +): Map> { + const result = new Map>(); + if (!backfillDefaults) return result; + const newPropKeys = Object.keys(properties); + for (const prop of percentagePropsOf(kfNode)) { + if (prop === skip.existingProp || skip.endpoints.has(prop)) continue; + const rec = backfilledSiblingRecord(prop.value, src, newPropKeys, backfillDefaults); + if (rec) result.set(prop, rec); + } + return result; +} + +export function addKeyframeToScript( + script: string, + animationId: string, + percentage: number, + properties: Record, + ease?: string, + backfillDefaults?: Record, +): string { + const located = ensureKeyframesNode(script, animationId); + if (!located) return script; + const { script: src, kfNode } = located; + + const existing = findKfPropByPct(kfNode, percentage); + + // Final record for the target keyframe (merge if it already exists). + const targetRecord = buildTargetRecord(existing, src, properties, ease); + // `_auto` endpoint syncs fire only on new inserts; a merge landing ON an + // endpoint already preserves `_auto` via buildTargetRecord. + const endpointOverwrites = existing + ? new Map>() + : autoEndpointOverwrites(kfNode, src, percentage, properties); + // Backfilled siblings (each node changes at most once). + const backfillOverwrites = collectBackfillOverwrites(kfNode, src, properties, backfillDefaults, { + existingProp: existing?.prop, + endpoints: endpointOverwrites, + }); + + // Emit exactly one overwrite per changed node, plus one insert for a new key. + const ms = new MagicString(src); + if (existing) { + // Merge into the existing keyframe at this percentage, preserving sibling + // properties — overwrite only the given keys. (A whole-value overwrite here + // would silently drop other properties already keyframed at this percent.) + if (existing.prop.value?.type === "ObjectExpression") { + for (const [k, v] of Object.entries(properties)) { + upsertProp(ms, existing.prop.value, k, v); + } + if (ease !== undefined) upsertProp(ms, existing.prop.value, "ease", ease); + } else { + ms.overwrite(existing.prop.value.start, existing.prop.value.end, recordToCode(targetRecord)); + } + } else { + insertNewKeyframe(ms, kfNode, percentage, `${percentage}%`, recordToCode(targetRecord)); + } + for (const [prop, rec] of [...endpointOverwrites, ...backfillOverwrites]) { + ms.overwrite(prop.value.start, prop.value.end, recordToCode(rec)); + } + + return ms.toString(); +} + +/** Insert a brand-new `"pct%": {...}` property in sorted order. */ +function insertNewKeyframe( + ms: MagicString, + kfNode: Node, + percentage: number, + pctKey: string, + valueCode: string, +): void { + const allProps = (kfNode.properties ?? []).filter((p: Node) => isObjectProperty(p)); + let insertBeforeProp: Node = null; + for (const prop of allProps) { + const key = propKeyName(prop); + if (typeof key === "string" && percentageFromKey(key) > percentage) { + insertBeforeProp = prop; + break; + } + } + if (insertBeforeProp) { + ms.appendLeft(insertBeforeProp.start, `${JSON.stringify(pctKey)}: ${valueCode}, `); + } else { + const sep = allProps.length > 0 ? ", " : ""; + ms.appendLeft(kfNode.end - 1, `${sep}${JSON.stringify(pctKey)}: ${valueCode}`); + } +} + +/** + * Rebuild a vars ObjectExpression that has just dropped below two keyframes, + * collapsing `keyframes: {…}` back to a flat tween. Mirrors recast's + * collapseKeyframesToFlat: drop the `keyframes` + `easeEach` keys, preserve every + * other vars key verbatim, and splice the remaining keyframe's properties (minus + * its per-keyframe `ease`) in as flat vars keys. Single ms.overwrite of the whole + * vars node so the splice can't overlap the keyframe removal. + */ +function collapseKeyframesToFlat( + ms: MagicString, + varsNode: Node, + source: string, + remainingRecord: Record, +): void { + if (varsNode?.type !== "ObjectExpression") return; + const dropKeyframeKeys = (key: string) => key === "keyframes" || key === "easeEach"; + const { entries } = preservedEntries(varsNode, source, dropKeyframeKeys, {}); + for (const [k, v] of Object.entries(remainingRecord)) { + if (k !== "ease") entries.push(`${safeKey(k)}: ${valueToCode(v)}`); + } + ms.overwrite(varsNode.start, varsNode.end, `{ ${entries.join(", ")} }`); +} + +/** Implicit tween-relative percentage of array-form keyframe index `i` of `n` + * (GSAP distributes array keyframes evenly: 0%, 1/(n-1), …, 100%). */ +function arrayKeyframePct(i: number, n: number): number { + return n > 1 ? (i / (n - 1)) * 100 : 0; +} + +// Array-form keyframes (`keyframes: [{x,y}, …]`) carry no explicit percentages — +// GSAP distributes them evenly. removeKeyframeFromScript only handled the +// object-form (`keyframes: { "50%": {…} }`), so removing from an array-form tween +// was a silent no-op (and the downstream hold-sync then stranded an `hf-hold`). +// Resolve the element by its implicit percentage and splice it out; collapse to a +// flat tween when fewer than two remain (parity with the object-form path). +function removeArrayKeyframe( + ms: MagicString, + varsArg: Node, + arrNode: Node, + script: string, + percentage: number, +): boolean { + const elements: Node[] = (arrNode.elements ?? []).filter( + (e: Node | null): e is Node => !!e && e.type === "ObjectExpression", + ); + const n = elements.length; + if (n === 0) return false; + + let matchIdx = -1; + let bestDist = Number.POSITIVE_INFINITY; + for (let i = 0; i < n; i++) { + const dist = Math.abs(arrayKeyframePct(i, n) - percentage); + if (dist <= PCT_TOLERANCE && dist < bestDist) { + matchIdx = i; + bestDist = dist; + } + } + if (matchIdx === -1) return false; + + const remaining = elements.filter((_, i) => i !== matchIdx); + if (remaining.length < 2) { + const sole = remaining[0]; + const record = sole ? valueNodeToRecord(sole, script) : {}; + collapseKeyframesToFlat(ms, varsArg, script, record); + return true; + } + removeProp(ms, elements[matchIdx], elements); + return true; +} + +export function removeKeyframeFromScript( + script: string, + animationId: string, + percentage: number, +): string { + const parsed = parseGsapScriptAcornForWrite(script); + if (!parsed) return script; + const target = parsed.located.find((l) => l.id === animationId); + if (!target) return script; + + const kfPropNode = findPropertyNode(target.call.varsArg, "keyframes"); + if (!kfPropNode) return script; + + if (kfPropNode.value?.type === "ArrayExpression") { + const ms = new MagicString(script); + return removeArrayKeyframe(ms, target.call.varsArg, kfPropNode.value, script, percentage) + ? ms.toString() + : script; + } + + if (kfPropNode.value?.type !== "ObjectExpression") return script; + const kfNode = kfPropNode.value; + + const match = findKfPropByPct(kfNode, percentage); + if (!match) return script; + + const ms = new MagicString(script); + + // If removing this keyframe leaves fewer than two, collapse the keyframes + // object back to a flat tween (recast parity) instead of leaving a lone + // keyframe. We rebuild the whole vars node, so we never also splice the kf + // node — the two edits would overlap. + const remaining = percentagePropsOf(kfNode).filter((p) => p !== match.prop); + if (remaining.length < 2) { + const sole = remaining[0]; + const record = sole ? valueNodeToRecord(sole.value, script) : {}; + collapseKeyframesToFlat(ms, target.call.varsArg, script, record); + return ms.toString(); + } + + const allProps = (kfNode.properties ?? []).filter((p: Node) => isObjectProperty(p)); + removeProp(ms, match.prop, allProps); + return ms.toString(); +} + +export function removePropertyFromAnimation( + script: string, + animationId: string, + property: string, + from = false, +): string { + const parsed = parseGsapScriptAcornForWrite(script); + if (!parsed) return script; + const target = parsed.located.find((l) => l.id === animationId); + if (!target) return script; + const { call } = target; + const objNode = from ? (call.method === "fromTo" ? call.fromArg : null) : call.varsArg; + if (!objNode) return script; + const propNode = findPropertyNode(objNode, property); + if (!propNode) return script; + const allProps = (objNode.properties ?? []).filter((p: Node) => isObjectProperty(p)); + const ms = new MagicString(script); + removeProp(ms, propNode, allProps); + return ms.toString(); +} + +/** + * Remove all keyframes from a tween, collapsing to a flat tween with one + * keyframe's properties: the first for `from()`, the last otherwise (the + * destination = the visible resting state). + */ +export function removeAllKeyframesFromScript(script: string, animationId: string): string { + const parsed = parseGsapScriptAcornForWrite(script); + if (!parsed) return script; + const target = parsed.located.find((l) => l.id === animationId); + if (!target) return script; + const kfs = target.animation.keyframes?.keyframes; + if (!kfs || kfs.length === 0) return script; + + const sorted = [...kfs].sort((a, b) => a.percentage - b.percentage); + const collapse = target.call.method === "from" ? sorted[0] : sorted[sorted.length - 1]; + if (!collapse) return script; + + const ms = new MagicString(script); + overwriteVarsArg( + ms, + target.call, + buildVarsObjectCode(buildCollapsedFlatVars(target.animation, collapse)), + ); + return ms.toString(); +} + +// Flat vars for a tween collapsing its keyframes onto one stop: existing +// top-level props, then the collapse keyframe's props (skip per-keyframe +// `ease`), then duration/ease/extras. Drops keyframes + easeEach by omission. +function buildCollapsedFlatVars( + animation: GsapAnimation, + collapse: { properties: Record }, +): Record { + const flat: Record = { ...animation.properties }; + for (const [k, v] of Object.entries(collapse.properties)) { + if (k !== "ease") flat[k] = v; + } + if (animation.duration !== undefined) flat.duration = animation.duration; + if (animation.ease) flat.ease = animation.ease; + for (const [k, v] of Object.entries(animation.extras ?? {})) { + if (typeof v === "number" || typeof v === "string") flat[k] = v; + } + return flat; +} + +/** Build the full replacement vars object for a tween being converted to keyframes. */ +function buildKeyframesVarsCode( + animation: GsapAnimation, + fromProps: Record, + toProps: Record, + varsNode: Node, + source: string, + setDuration?: number, +): string { + const fromEntries = Object.entries(fromProps).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`); + const toEntries = Object.entries(toProps).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`); + const easeEntry = animation.ease ? `, easeEach: ${JSON.stringify(animation.ease)}` : ""; + const kfCode = `{ "0%": { ${fromEntries.join(", ")} }, "100%": { ${toEntries.join(", ")} }${easeEntry} }`; + // Preserve every non-editable key (duration/delay/callbacks/stagger/yoyo/…) + // verbatim from source — rebuilding from the animation object alone dropped + // `delay` (not a GsapAnimation field), shifting the tween's start time. + let preserved = preservedVarsEntries(varsNode, source); + // Converting a static `set` → drop its hold markers and give it a real duration + // so the keyframes span time. + if (setDuration !== undefined) { + preserved = preserved.filter((e) => !/^\s*(immediateRender|data|duration)\s*:/.test(e)); + } + const parts: string[] = [`keyframes: ${kfCode}`, ...preserved]; + if (setDuration !== undefined) parts.push(`duration: ${Math.max(0.001, setDuration)}`); + if (animation.ease) parts.push(`ease: "none"`); + return `{ ${parts.join(", ")} }`; +} + +/** + * Convert a flat tween (to/from/fromTo) to percentage-keyframes format. + * `resolvedFromValues` supplies the current DOM state: overrides the 0% endpoint + * for `to()`, the 100% endpoint for `from()`, or merges into toProps for `fromTo()`. + */ +export function convertToKeyframesFromScript( + script: string, + animationId: string, + resolvedFromValues?: Record, + setDuration = 1, +): string { + const parsed = parseGsapScriptAcornForWrite(script); + if (!parsed) return script; + const target = parsed.located.find((l) => l.id === animationId); + if (!target) return script; + const { animation, call } = target; + if (animation.keyframes) return script; + const isSet = call.method === "set"; + + const { fromProps, toProps } = resolveConversionProps(animation, resolvedFromValues); + const ms = new MagicString(script); + + // A GLOBAL `gsap.set(...)` is off-timeline; rewriting only the method emits + // `gsap.to(...)`, which fires once at load and isn't on the paused master + // timeline (the engine can't seek/render it). Re-root onto the timeline var + // and add the position arg the set lacks so the converted tween is seekable. + if (isSet && animation.global) { + const calleeObj = call.node.callee.object; + if (calleeObj?.type === "Identifier") { + ms.overwrite(calleeObj.start, calleeObj.end, parsed.timelineVar); + } + const args = call.node.arguments; + if (args.length > 0 && args.length < 3) { + ms.appendLeft(args[args.length - 1].end, ", 0"); + } + } + + // set/from/fromTo all become `to`; fromTo also drops its `from` argument. + if (call.method === "from" || call.method === "fromTo" || isSet) { + ms.overwrite(call.node.callee.property.start, call.node.callee.property.end, "to"); + } + if (call.method === "fromTo" && call.fromArg) { + ms.remove(call.fromArg.start, call.varsArg.start); + } + overwriteVarsArg( + ms, + call, + buildKeyframesVarsCode( + animation, + fromProps, + toProps, + call.varsArg, + script, + isSet ? setDuration : undefined, + ), + ); + + return ms.toString(); +} + +// ── Keyframe-object code builder ───────────────────────────────────────────── + +/** Build a percentage-keyframes object literal: `{ "0%": { x: 0 }, "100%": { x: 100 } }`. */ +function buildKeyframeObjectCode( + keyframes: Array<{ + percentage: number; + properties: Record; + ease?: string; + auto?: boolean; + }>, + easeEach?: string, +): string { + const entries = keyframes.map((kf) => { + const props = Object.entries(kf.properties).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`); + if (kf.ease) props.push(`ease: ${JSON.stringify(kf.ease)}`); + if (kf.auto) props.push(`_auto: 1`); + return `${JSON.stringify(`${kf.percentage}%`)}: { ${props.join(", ")} }`; + }); + if (easeEach) entries.push(`easeEach: ${JSON.stringify(easeEach)}`); + return `{ ${entries.join(", ")} }`; +} + +// ── Materialize keyframes ──────────────────────────────────────────────────── + +/** + * Replace a dynamic or static keyframes expression with a fully-resolved + * percentage-keyframes object. Called when a user first edits a dynamically- + * generated keyframe in the studio so it becomes statically editable. + */ +export function materializeKeyframesFromScript( + script: string, + animationId: string, + keyframes: Array<{ + percentage: number; + properties: Record; + ease?: string; + }>, + easeEach?: string, + resolvedSelector?: string, +): string { + // An empty keyframe list has no materialized form — rebuilding vars with an + // empty keyframes object would empty the animation. No-op instead. + if (keyframes.length === 0) return script; + const parsed = parseGsapScriptAcornForWrite(script); + if (!parsed) return script; + const target = parsed.located.find((l) => l.id === animationId); + if (!target) return script; + + const { call } = target; + const sorted = [...keyframes].sort((a, b) => a.percentage - b.percentage); + const kfObjCode = buildKeyframeObjectCode(sorted, easeEach); + const ms = new MagicString(script); + + if (resolvedSelector) { + const selectorArg = call.node.arguments[0]; + if (selectorArg) + ms.overwrite(selectorArg.start, selectorArg.end, JSON.stringify(resolvedSelector)); + } + + const kfProp = findPropertyNode(call.varsArg, "keyframes"); + if (kfProp) { + ms.overwrite(kfProp.value.start, kfProp.value.end, kfObjCode); + } else if (call.varsArg?.type === "ObjectExpression") { + const vars = call.varsArg; + if (vars.properties.length > 0) { + ms.prependLeft(vars.properties[0].start, `keyframes: ${kfObjCode}, `); + } else { + ms.appendLeft(vars.end - 1, `keyframes: ${kfObjCode}`); + } + } + + const eachProp = findPropertyNode(call.varsArg, "easeEach"); + if (eachProp) { + const allProps = (call.varsArg.properties ?? []).filter((p: Node) => isObjectProperty(p)); + removeProp(ms, eachProp, allProps); + } + + return ms.toString(); +} + +// ── Add animation with keyframes ────────────────────────────────────────────── + +/** Insert a new keyframed `to()` call and return the new animation ID. */ +export function addAnimationWithKeyframesToScript( + script: string, + targetSelector: string, + position: number, + duration: number, + keyframes: Array<{ + percentage: number; + properties: Record; + ease?: string; + auto?: boolean; + }>, + ease?: string, + easeEach?: string, +): { script: string; id: string } { + const parsed = parseGsapScriptAcornForWrite(script); + if (!parsed) return { script, id: "" }; + const insertionPoint = findInsertionPoint(parsed); + if (insertionPoint === null) return { script, id: "" }; + + const sorted = [...keyframes].sort((a, b) => a.percentage - b.percentage); + const kfObjCode = buildKeyframeObjectCode(sorted, easeEach); + const varParts = [`keyframes: ${kfObjCode}`, `duration: ${valueToCode(duration)}`]; + if (ease) varParts.push(`ease: ${JSON.stringify(ease)}`); + const stmtCode = `${parsed.timelineVar}.to(${JSON.stringify(targetSelector)}, { ${varParts.join(", ")} }, ${valueToCode(position)});`; + + const ms = new MagicString(script); + ms.appendLeft(insertionPoint, "\n" + stmtCode); + + const result = ms.toString(); + const reParsed = parseGsapScriptAcornForWrite(result); + const newId = reParsed?.located[reParsed.located.length - 1]?.id ?? ""; + return { script: result, id: newId }; +} + +// ── Split into property groups ──────────────────────────────────────────────── + +function collectPropertyKeys(anim: GsapAnimation): Set { + const keys = new Set(); + if (anim.keyframes) { + for (const kf of anim.keyframes.keyframes) { + for (const k of Object.keys(kf.properties)) keys.add(k); + } + } else { + for (const k of Object.keys(anim.properties)) keys.add(k); + } + return keys; +} + +function partitionPropertyGroups(keys: Set): Map { + const groups = new Map(); + for (const key of keys) { + if (key === "transformOrigin") continue; + const group = classifyPropertyGroup(key); + let arr = groups.get(group); + if (!arr) { + arr = []; + groups.set(group, arr); + } + arr.push(key); + } + return groups; +} + +function assignTransformOrigin(groupProps: Map): void { + let largestGroup: PropertyGroupName | undefined; + let largestCount = 0; + for (const [group, props] of groupProps) { + if (props.length > largestCount) { + largestCount = props.length; + largestGroup = group; + } + } + const largest = largestGroup ? groupProps.get(largestGroup) : undefined; + if (largest) largest.push("transformOrigin"); +} + +function filterGroupKeyframes( + kfs: GsapPercentageKeyframe[], + propSet: Set, +): Array<{ percentage: number; properties: Record; ease?: string }> { + const result: Array<{ + percentage: number; + properties: Record; + ease?: string; + }> = []; + for (const kf of kfs) { + const filtered: Record = {}; + for (const [k, v] of Object.entries(kf.properties)) { + if (propSet.has(k)) filtered[k] = v; + } + if (Object.keys(filtered).length > 0) { + result.push({ + percentage: kf.percentage, + properties: filtered, + ...(kf.ease ? { ease: kf.ease } : {}), + }); + } + } + return result; +} + +function filterGroupProperties( + properties: Record, + propSet: Set, +): Record { + const result: Record = {}; + for (const [k, v] of Object.entries(properties)) { + if (propSet.has(k)) result[k] = v; + } + return result; +} + +function addGroupAnimToScript( + script: string, + anim: GsapAnimation, + propSet: Set, +): { script: string; id: string } { + if (anim.keyframes) { + const groupKeyframes = filterGroupKeyframes(anim.keyframes.keyframes, propSet); + if (groupKeyframes.length === 0) return { script, id: "" }; + const pos = typeof anim.position === "number" ? anim.position : 0; + return addAnimationWithKeyframesToScript( + script, + anim.targetSelector, + pos, + anim.duration ?? 0.5, + groupKeyframes, + anim.keyframes.easeEach ?? anim.ease, + ); + } + const groupProperties = filterGroupProperties(anim.properties, propSet); + if (Object.keys(groupProperties).length === 0) return { script, id: "" }; + const fromProperties = + anim.method === "fromTo" && anim.fromProperties + ? filterGroupProperties(anim.fromProperties, propSet) + : undefined; + return addAnimationToScript(script, { + targetSelector: anim.targetSelector, + method: anim.method, + position: anim.position, + duration: anim.duration, + ease: anim.ease, + properties: groupProperties, + fromProperties, + extras: anim.extras, + }); +} + +/** + * Split a mixed-property tween into one tween per property group (position, + * scale, visual, etc.) so each group can be edited independently. + * Returns the updated script and the IDs of the newly-created tweens. + */ +export function splitIntoPropertyGroupsFromScript( + script: string, + animationId: string, +): { script: string; ids: string[] } { + const parsed = parseGsapScriptAcornForWrite(script); + if (!parsed) return { script, ids: [animationId] }; + const target = parsed.located.find((l) => l.id === animationId); + if (!target) return { script, ids: [animationId] }; + const { animation } = target; + + const allPropKeys = collectPropertyKeys(animation); + const groupProps = partitionPropertyGroups(allPropKeys); + if (groupProps.size <= 1) return { script, ids: [animationId] }; + if (allPropKeys.has("transformOrigin")) assignTransformOrigin(groupProps); + + let result = removeAnimationFromScript(script, animationId); + for (const [, props] of groupProps) { + const { script: next, id } = addGroupAnimToScript(result, animation, new Set(props)); + if (id) result = next; + } + + const reParsed = parseGsapScriptAcornForWrite(result); + const newIds = (reParsed?.located ?? []) + .filter((l) => l.animation.targetSelector === animation.targetSelector) + .map((l) => l.id); + return { script: result, ids: newIds }; +} + +// ── Label write ops ─────────────────────────────────────────────────────────── + +/** True when `expr` is `tl.(…)` rooted at the timeline var. */ +function isTimelineMethodCall(expr: Node, timelineVar: string, method: string): boolean { + return ( + expr?.type === "CallExpression" && + expr.callee?.type === "MemberExpression" && + isTimelineRooted(expr.callee.object, timelineVar) && + expr.callee.property?.name === method + ); +} + +/** True when `expr` is `tl.addLabel("", …)` rooted at the timeline var. */ +function isAddLabelCall(expr: Node, timelineVar: string, name: string): boolean { + const firstArg = expr?.arguments?.[0]; + return ( + isTimelineMethodCall(expr, timelineVar, "addLabel") && + firstArg?.type === "Literal" && + firstArg.value === name + ); +} + +/** Every `tl.addLabel("", …)` ExpressionStatement in the script. */ +function findLabelStatements(parsed: ParsedGsapAcornForWrite, name: string): Node[] { + const targets: Node[] = []; + acornWalk.simple(parsed.ast, { + ExpressionStatement(node: Node) { + if (isAddLabelCall(node.expression, parsed.timelineVar, name)) targets.push(node); + }, + }); + return targets; +} + +export function addLabelToScript(script: string, name: string, position: number): string { + const parsed = parseGsapScriptAcornForWrite(script); + if (!parsed) return script; + + // If the label already exists, MOVE it (overwrite its position) rather than + // appending a duplicate. Two same-named addLabel statements make removeLabel + // over-remove — it deletes every match, including a pre-existing label the + // user never touched. + const existing = findLabelStatements(parsed, name)[0]; + if (existing) { + const ms = new MagicString(script); + const posArg = existing.expression.arguments?.[1]; + if (posArg) ms.overwrite(posArg.start, posArg.end, valueToCode(position)); + else ms.appendLeft(existing.expression.end - 1, `, ${valueToCode(position)}`); + return ms.toString(); + } + + 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 = findLabelStatements(parsed, name); + 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(); +} + +// ── Arc path helpers ───────────────────────────────────────────────────────── + +/** + * Remove a set of properties from an ObjectExpression in a single pass. + * Groups consecutive marked props into blocks to avoid overlapping remove ranges. + */ +function removePropsByKey(ms: MagicString, objNode: Node, keys: Set): void { + if (objNode?.type !== "ObjectExpression") return; + const allProps = (objNode.properties ?? []).filter(isObjectProperty); + const marked = allProps.map((p: Node) => keys.has(propKeyName(p) ?? "")); + let i = 0; + while (i < allProps.length) { + if (!marked[i]) { + i++; + continue; + } + const blockStart = i; + while (i < allProps.length && marked[i]) i++; + ms.remove(...blockRemoveRange(allProps, blockStart, i)); + } +} + +function blockRemoveRange( + allProps: Node[], + blockStart: number, + blockEnd: number, +): [number, number] { + if (blockStart === 0 && blockEnd === allProps.length) + return [allProps[0].start, allProps[allProps.length - 1].end]; + if (blockStart === 0) return [allProps[0].start, allProps[blockEnd].start]; + return [allProps[blockStart - 1].end, allProps[blockEnd - 1].end]; +} + +// fallow-ignore-next-line complexity +function readLastWaypointXY(mpVal: Node): { x: number | null; y: number | null } { + if (mpVal?.type !== "ObjectExpression") return { x: null, y: null }; + const pathProp = findPropertyNode(mpVal, "path"); + if (pathProp?.value?.type !== "ArrayExpression") return { x: null, y: null }; + const elems: Node[] = pathProp.value.elements ?? []; + const last = elems[elems.length - 1]; + if (last?.type !== "ObjectExpression") return { x: null, y: null }; + return { + x: readNumericLiteralNode(findPropertyNode(last, "x")?.value), + y: readNumericLiteralNode(findPropertyNode(last, "y")?.value), + }; +} + +/** + * Read a numeric value node — a plain numeric literal or a unary-minus negative + * literal (e.g. `-120`). Returns null for anything non-numeric. Without the + * UnaryExpression branch, negative waypoint coords (parsed as a UnaryExpression + * with no `.value`) would be lost when disabling an arc path. + */ +function readNumericLiteralNode(v: Node): number | null { + if (LITERAL_NODE_TYPES.has(v?.type) && typeof v.value === "number") return v.value; + if ( + v?.type === "UnaryExpression" && + v.operator === "-" && + typeof v.argument?.value === "number" + ) { + return -v.argument.value; + } + return null; +} + +function disableArcPath(ms: MagicString, call: TweenCallInfo): boolean { + const mpProp = findPropertyNode(call.varsArg, "motionPath"); + if (!mpProp) return false; + const { x, y } = readLastWaypointXY(mpProp.value); + if (x === null && y === null) { + const allProps = (call.varsArg.properties ?? []).filter(isObjectProperty); + removeProp(ms, mpProp, allProps); + return true; + } + // Overwrite the entire motionPath property with the recovered x/y pair — avoids + // the appendLeft+remove range-boundary issue in MagicString. + const parts: string[] = []; + if (x !== null) parts.push(`x: ${x}`); + if (y !== null) parts.push(`y: ${y}`); + ms.overwrite(mpProp.start, mpProp.end, parts.join(", ")); + return true; +} + +function stripXYFromKeyframes(ms: MagicString, kfPropNode: Node): void { + if (kfPropNode?.value?.type !== "ObjectExpression") return; + const xyKeys = new Set(["x", "y"]); + for (const pctProp of (kfPropNode.value.properties ?? []).filter(isObjectProperty)) { + const k = propKeyName(pctProp); + if (typeof k === "string" && k.endsWith("%") && pctProp.value?.type === "ObjectExpression") { + removePropsByKey(ms, pctProp.value, xyKeys); + } + } +} + +function enableArcPath( + ms: MagicString, + call: TweenCallInfo, + animation: GsapAnimation, + config: ArcPathConfig, +): boolean { + const waypoints = extractArcWaypoints(animation); + if (waypoints.length < 2) return false; + const segments: ArcPathSegment[] = + config.segments.length === waypoints.length - 1 + ? config.segments + : Array.from({ length: waypoints.length - 1 }, () => ({ curviness: 1 })); + const motionPathCode = buildMotionPathObjectCode({ + waypoints, + segments, + autoRotate: config.autoRotate, + }); + const vars = call.varsArg; + if (vars?.type !== "ObjectExpression") return false; + // Insert motionPath right after the opening `{` (appendRight at start+1) so the + // insertion point can never coincide with the end boundary of the x/y removal + // range. upsertProp would appendLeft at `end - 1`, which collides with a + // remove-range that ends at the same offset when x/y are the only props — + // MagicString then discards the append and the output loses everything. + const editable = (vars.properties ?? []).filter(isObjectProperty); + const survivesRemoval = editable.some((p: Node) => { + const k = propKeyName(p); + return k !== "x" && k !== "y"; + }); + const sep = survivesRemoval ? ", " : ""; + ms.appendRight(vars.start + 1, ` motionPath: ${motionPathCode}${sep}`); + stripXYFromKeyframes(ms, findPropertyNode(call.varsArg, "keyframes")); + removePropsByKey(ms, call.varsArg, new Set(["x", "y"])); + return true; +} + +export function setArcPathInScript( + script: string, + animationId: string, + config: ArcPathConfig, +): string { + const parsed = parseGsapScriptAcornForWrite(script); + if (!parsed) return script; + const target = parsed.located.find((l) => l.id === animationId); + if (!target) return script; + const ms = new MagicString(script); + const handled = config.enabled + ? enableArcPath(ms, target.call, target.animation, config) + : disableArcPath(ms, target.call); + return handled ? ms.toString() : script; +} + +export function updateArcSegmentInScript( + script: string, + animationId: string, + segmentIndex: number, + update: Partial, +): string { + const parsed = parseGsapScriptAcornForWrite(script); + if (!parsed) return script; + const target = parsed.located.find((l) => l.id === animationId); + if (!target) return script; + + const { call, animation } = target; + if (!animation.arcPath?.enabled) return script; + + const segments = [...animation.arcPath.segments]; + const existingSeg = segments[segmentIndex]; + if (segmentIndex < 0 || segmentIndex >= segments.length || !existingSeg) return script; + + segments[segmentIndex] = { ...existingSeg, ...update }; + + const waypoints = extractArcWaypoints(animation); + if (waypoints.length < 2) return script; + + const motionPathCode = buildMotionPathObjectCode({ + waypoints, + segments, + autoRotate: animation.arcPath.autoRotate, + }); + + const mpProp = findPropertyNode(call.varsArg, "motionPath"); + if (!mpProp) return script; + + const ms = new MagicString(script); + ms.overwrite(mpProp.value.start, mpProp.value.end, motionPathCode); + return ms.toString(); +} + +export function removeArcPathFromScript(script: string, animationId: string): string { + return setArcPathInScript(script, animationId, { + enabled: false, + autoRotate: false, + segments: [], + }); +} + +// ── splitAnimationsInScript helpers ────────────────────────────────────────── + +/** Overwrite the selector (first arg) of a tween call. */ +function updateAnimationSelectorInScript( + script: string, + animationId: string, + newSelector: string, +): string { + const parsed = parseGsapScriptAcornForWrite(script); + if (!parsed) return script; + const target = parsed.located.find((l) => l.id === animationId); + if (!target) return script; + const selectorArg = target.call.node.arguments?.[0]; + if (!selectorArg) return script; + const ms = new MagicString(script); + ms.overwrite(selectorArg.start, selectorArg.end, JSON.stringify(newSelector)); + return ms.toString(); +} + +/** + * Insert a `tl.set()` call immediately after the timeline declaration + * (before existing tweens) to establish inherited state on a new element. + */ +function insertInheritedStateSetInScript( + script: string, + selector: string, + position: number, + properties: Record, +): string { + const parsed = parseGsapScriptAcornForWrite(script); + if (!parsed) return script; + const props = Object.entries(properties) + .map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`) + .join(", "); + const code = `${parsed.timelineVar}.set(${JSON.stringify(selector)}, { ${props} }, ${position});`; + const ms = new MagicString(script); + const tlDecl = findTimelineDeclarationStatement(parsed.ast, parsed.timelineVar); + const firstLocated = parsed.located[0]; + if (tlDecl) { + ms.appendLeft(tlDecl.end, "\n" + code); + } else if (firstLocated) { + const firstCall = firstLocated.call; + const exprStmt = findEnclosingExpressionStatement(firstCall.ancestors); + const insertAt = exprStmt?.start ?? firstCall.node.start; + ms.prependLeft(insertAt, code + "\n"); + } else { + ms.append("\n" + code); + } + return ms.toString(); +} + +/** + * Compute, in forward (timeline) order, the inherited-props baseline available + * BEFORE each matching tween, plus the final cumulative state at the split point. + * A tween contributes to later baselines when it ends at/before the split (full + * props or last keyframe), spans the split via keyframes (kfs at/before split), + * or spans the split as a flat tween (its interpolated midpoint). Decoupled from + * the reverse write loop so the spanning-tween midpoint reads earlier tweens. + */ +// fallow-ignore-next-line complexity +function computeForwardBaselines( + matching: GsapAnimation[], + splitTime: number, +): { before: Array>; final: Record } { + const before: Array> = []; + const acc: Record = {}; + for (const anim of matching) { + before.push({ ...acc }); + const pos = typeof anim.position === "number" ? anim.position : 0; + const dur = anim.duration ?? 0; + const animEnd = pos + dur; + + if (anim.keyframes) { + const kfs = anim.keyframes.keyframes; + if (pos >= splitTime) { + // Moves wholly to the new element — contributes nothing to the baseline. + } else if (animEnd > splitTime) { + for (const kf of kfs) { + const kfTime = pos + (kf.percentage / 100) * dur; + if (kfTime <= splitTime) { + for (const [k, v] of Object.entries(kf.properties)) acc[k] = v; + } + } + } else { + const lastKf = kfs[kfs.length - 1]; + if (lastKf) { + for (const [k, v] of Object.entries(lastKf.properties)) acc[k] = v; + } + } + continue; + } + + if (animEnd <= splitTime) { + for (const [k, v] of Object.entries(anim.properties)) acc[k] = v; + continue; + } + + if (pos >= splitTime) continue; + + // Flat tween spanning the split — its midpoint becomes the inherited value. + const progress = dur > 0 ? (splitTime - pos) / dur : 0; + const fromSource = anim.fromProperties ?? acc; + for (const [k, v] of Object.entries(anim.properties)) { + if (typeof v !== "number") { + acc[k] = v; + continue; + } + const fromVal = typeof fromSource[k] === "number" ? (fromSource[k] as number) : 0; + acc[k] = fromVal + (v - fromVal) * progress; + } + } + return { before, final: { ...acc } }; +} + +// Split one tween that straddles the split point: trim the original to the +// first half (interpolated midpoint as its new end) and add a fromTo for the +// second half on the new element. `fromSource` is the forward baseline. +function buildSpanningSplit( + result: string, + anim: GsapAnimation, + pos: number, + dur: number, + fromSource: Record, + ctx: { splitTime: number; newSelector: string; newElementStart: number }, +): string { + const progress = dur > 0 ? (ctx.splitTime - pos) / dur : 0; + const midProps: Record = {}; + for (const [k, v] of Object.entries(anim.properties)) { + if (typeof v !== "number") { + midProps[k] = v; + continue; + } + const fromVal = typeof fromSource[k] === "number" ? (fromSource[k] as number) : 0; + midProps[k] = fromVal + (v - fromVal) * progress; + } + const trimmed = updateAnimationInScript(result, anim.id, { + duration: ctx.splitTime - pos, + properties: midProps, + }); + return addAnimationToScript(trimmed, { + targetSelector: ctx.newSelector, + method: "fromTo", + position: ctx.newElementStart, + duration: pos + dur - ctx.splitTime, + properties: { ...anim.properties }, + fromProperties: { ...midProps }, + ease: anim.ease, + extras: anim.extras, + }).script; +} + +type SplitCtx = { + splitTime: number; + originalSelector: string; + newSelector: string; + newElementStart: number; +}; + +// Decide what one matching tween does at the split point: move to the new +// element (wholly after), stay (wholly before / keyframes before), get skipped +// (keyframes spanning), or get interpolated in half (spanning). Returns the +// updated script; pushes any skip reason into `skippedSelectors`. +function applyTweenSplit( + result: string, + anim: GsapAnimation, + baselineBefore: Record, + ctx: SplitCtx, + skippedSelectors: string[], +): string { + const pos = typeof anim.position === "number" ? anim.position : 0; + const dur = anim.duration ?? 0; + const animEnd = pos + dur; + + if (anim.keyframes) { + if (pos >= ctx.splitTime) + return updateAnimationSelectorInScript(result, anim.id, ctx.newSelector); + if (animEnd > ctx.splitTime) { + skippedSelectors.push(`${ctx.originalSelector} (keyframes spanning split)`); + } + // Inherited-state for kf tweens is handled by computeForwardBaselines. + return result; + } + // Wholly before the split — kept on the original element. + if (animEnd <= ctx.splitTime) return result; + // Wholly after — move to the new element. + if (pos >= ctx.splitTime) + return updateAnimationSelectorInScript(result, anim.id, ctx.newSelector); + // Spans the split — interpolate the midpoint from the FORWARD baseline. + const fromSource = anim.fromProperties ?? baselineBefore; + return buildSpanningSplit(result, anim, pos, dur, fromSource, ctx); +} + +export function splitAnimationsInScript( + script: string, + opts: SplitAnimationsOptions, +): SplitAnimationsResult { + const parsed = parseGsapScriptAcornForWrite(script); + if (!parsed) return { script, skippedSelectors: [] }; + + const originalSelector = `#${opts.originalId}`; + const newSelector = `#${opts.newId}`; + + const animations = parsed.located.map((l) => l.animation); + const skippedSelectors: string[] = []; + + for (const a of animations) { + if (a.targetSelector !== originalSelector && a.targetSelector.includes(opts.originalId)) { + skippedSelectors.push(a.targetSelector); + } + } + + const matching = animations.filter((a) => a.targetSelector === originalSelector); + if (matching.length === 0) return { script, skippedSelectors }; + + let result = script; + const newElementStart = opts.splitTime; + + // Forward pre-pass: compute the inherited-props baseline available BEFORE each + // matching tween, in source/timeline order. The write loop below runs in + // REVERSE (so updateAnimationSelectorInScript's selector edits can't shift the + // count-based IDs of not-yet-processed tweens), but the spanning-tween midpoint + // interpolation needs the baseline from EARLIER tweens — which a reverse + // accumulator hasn't seen yet. Decoupling the two fixes the wrong midpoint. + const { before: baselineBefore, final: finalInheritedProps } = computeForwardBaselines( + matching, + opts.splitTime, + ); + + // Reverse iteration: updateAnimationSelectorInScript mutates selectors which + // can shift count-based ID suffixes for later animations. + const ctx = { splitTime: opts.splitTime, originalSelector, newSelector, newElementStart }; + for (let i = matching.length - 1; i >= 0; i--) { + const anim = matching[i]; + if (!anim) continue; + result = applyTweenSplit(result, anim, baselineBefore[i] ?? {}, ctx, skippedSelectors); + } + + if (Object.keys(finalInheritedProps).length > 0) { + result = insertInheritedStateSetInScript( + result, + newSelector, + newElementStart, + finalInheritedProps, + ); + } + + return { script: result, skippedSelectors }; +} + +// ── Unroll dynamic animations ──────────────────────────────────────────────── + +function isLoopNode(node: Node): boolean { + const t = node?.type; + return ( + t === "ForStatement" || + t === "ForInStatement" || + t === "ForOfStatement" || + t === "WhileStatement" + ); +} + +function isForEachStatement(node: Node): boolean { + return ( + node?.type === "ExpressionStatement" && + node.expression?.type === "CallExpression" && + node.expression.callee?.property?.name === "forEach" + ); +} + +/** The nearest enclosing loop / forEach AST node (not just its byte range). */ +function findEnclosingLoopNode(ancestors: Node[]): Node | null { + for (let i = ancestors.length - 2; i >= 0; i--) { + const node = ancestors[i]; + if (isLoopNode(node) || isForEachStatement(node)) return node; + } + return null; +} + +/** Statements making up a loop's body block, or null when not a simple block. */ +function loopBodyStatements(loopNode: Node): Node[] | null { + let body: Node; + if (loopNode?.type === "ExpressionStatement") { + // forEach(cb): body is the callback's block. + const cb = loopNode.expression?.arguments?.[0]; + body = cb?.body; + } else { + body = loopNode?.body; + } + if (body?.type !== "BlockStatement") return null; + return (body.body ?? []).filter((s: Node) => s?.type === "ExpressionStatement"); +} + +/** The loop's index identifier name (`for (let i …)`), used for per-iteration substitution. */ +function loopIndexVarName(loopNode: Node): string | null { + if (loopNode?.type === "ForStatement") { + const decl = loopNode.init?.declarations?.[0]; + return typeof decl?.id?.name === "string" ? decl.id.name : null; + } + return null; +} + +/** + * Rewrite one body statement's source for iteration `idx`: replace USES of the + * loop index variable (AST Identifier nodes) with the literal index. AST-based, + * not a text regex, so the index name appearing inside a string literal (e.g. a + * selector ".row-i") or as a non-computed member/key (`obj.i`, `{ i: … }`) is + * left untouched — only real references to the variable are substituted. + */ +// An identifier in "binding position" is a name, not a value reference: a +// non-computed member property (`obj.i`) or object-literal key (`{ i: … }`). +// Those must NOT be substituted with the iteration index. +function isIndexBindingPosition(node: Node, parent: Node): boolean { + if (parent?.type === "MemberExpression") return parent.property === node && !parent.computed; + if (parent?.type === "Property" || parent?.type === "ObjectProperty") { + return parent.key === node && !parent.computed; + } + return false; +} + +function substituteLoopIndex(stmt: Node, indexVar: string, idx: number, script: string): string { + const base = stmt.start as number; + const src = script.slice(base, stmt.end as number); + const ranges: Array<[number, number]> = []; + acornWalk.ancestor(stmt, { + Identifier(node: Node, _state: unknown, ancestors: Node[]) { + if (node.name !== indexVar) return; + if (isIndexBindingPosition(node, ancestors[ancestors.length - 2])) return; + ranges.push([(node.start as number) - base, (node.end as number) - base]); + }, + }); + if (ranges.length === 0) return src; + ranges.sort((a, b) => b[0] - a[0]); + let out = src; + for (const [s, e] of ranges) out = out.slice(0, s) + String(idx) + out.slice(e); + return out; +} + +function buildUnrollReplacement( + timelineVar: string, + animation: GsapAnimation, + elements: Array<{ + selector: string; + keyframes: Array<{ percentage: number; properties: Record }>; + easeEach?: string; + }>, +): string { + const duration = typeof animation.duration === "number" ? animation.duration : 8; + const ease = typeof animation.ease === "string" ? animation.ease : "none"; + const pos = animation.position ?? 0; + const posCode = typeof pos === "number" ? String(pos) : JSON.stringify(pos); + const calls = elements.map((el) => { + const sorted = [...el.keyframes].sort((a, b) => a.percentage - b.percentage); + const kfCode = buildKeyframeObjectCode(sorted, el.easeEach); + return `${timelineVar}.to(${JSON.stringify(el.selector)}, { keyframes: ${kfCode}, duration: ${duration}, ease: ${JSON.stringify(ease)} }, ${posCode});`; + }); + return calls.join("\n "); +} + +export type UnrollElement = { + selector: string; + keyframes: Array<{ percentage: number; properties: Record }>; + easeEach?: string; +}; + +/** Build one element's unrolled `tl.to(...)` call from the target animation. */ +function buildUnrollCallForElement( + timelineVar: string, + animation: GsapAnimation, + el: UnrollElement, +): string { + const duration = typeof animation.duration === "number" ? animation.duration : 8; + const ease = typeof animation.ease === "string" ? animation.ease : "none"; + const pos = animation.position ?? 0; + const posCode = typeof pos === "number" ? String(pos) : JSON.stringify(pos); + const sorted = [...el.keyframes].sort((a, b) => a.percentage - b.percentage); + const kfCode = buildKeyframeObjectCode(sorted, el.easeEach); + return `${timelineVar}.to(${JSON.stringify(el.selector)}, { keyframes: ${kfCode}, duration: ${duration}, ease: ${JSON.stringify(ease)} }, ${posCode});`; +} + +/** Sentinel: the unroll cannot safely reproduce the loop body — caller no-ops. */ +const REFUSE_UNROLL = Symbol("refuse-unroll"); + +/** Every statement in a loop's body block (unfiltered), or [] when not a block. */ +function loopBodyRawStatements(loopNode: Node): Node[] { + const body = + loopNode?.type === "ExpressionStatement" + ? loopNode.expression?.arguments?.[0]?.body + : loopNode?.body; + return body?.type === "BlockStatement" ? (body.body ?? []) : []; +} + +/** A node that re-binds `indexVar`: a re-declaration or a function param. */ +function rebindsIndex(node: Node, indexVar: string): boolean { + if (node.type === "VariableDeclarator") return node.id?.name === indexVar; + if ( + node.type === "FunctionExpression" || + node.type === "FunctionDeclaration" || + node.type === "ArrowFunctionExpression" + ) { + return (node.params ?? []).some((p: Node) => p?.name === indexVar); + } + return false; +} + +/** Object shorthand `{ i }` — substituting the value would yield invalid `{ 0 }`. */ +function isShorthandIndexUse(node: Node, indexVar: string): boolean { + return ( + (node.type === "Property" || node.type === "ObjectProperty") && + node.shorthand === true && + propKeyName(node) === indexVar + ); +} + +/** + * A sibling statement can't be safely index-substituted when it re-binds the + * loop index (shadowing — a nested `for (let i …)`, a callback param `i`) or + * uses it in object shorthand (`{ i }`, which would splice to the invalid + * `{ 0 }`). substituteLoopIndex has no scope analysis, so in these cases it + * would emit broken or wrong code — the unroll must refuse instead. + */ +function hasUnsafeLoopIndexUse(stmt: Node, indexVar: string): boolean { + let unsafe = false; + acornWalk.full(stmt, (node: Node) => { + if (!unsafe && (isShorthandIndexUse(node, indexVar) || rebindsIndex(node, indexVar))) { + unsafe = true; + } + }); + return unsafe; +} + +/** How to handle the loop body's non-target siblings when unrolling. */ +function unrollSiblingStrategy( + loopNode: Node, + targetStmt: Node, + stmts: Node[], + indexVar: string | null, +): "blanket" | "refuse" | "preserve" { + const siblings = stmts.filter((s) => s !== targetStmt); + // A sibling the filtered statement list doesn't model (non-ExpressionStatement) + // would be silently lost by either path — refuse if any exists. + const hasUnmodeledSibling = loopBodyRawStatements(loopNode).some( + (s) => s !== targetStmt && !stmts.includes(s), + ); + if (siblings.length === 0 && !hasUnmodeledSibling) return "blanket"; + if (hasUnmodeledSibling || !indexVar) return "refuse"; + return siblings.some((s) => hasUnsafeLoopIndexUse(s, indexVar)) ? "refuse" : "preserve"; +} + +/** Emit the per-iteration unrolled lines (target → static tl.to, siblings → index-substituted). */ +function emitUnrolledLines( + stmts: Node[], + targetStmt: Node, + elements: UnrollElement[], + timelineVar: string, + animation: GsapAnimation, + indexVar: string, + script: string, +): string { + const lines: string[] = []; + for (let idx = 0; idx < elements.length; idx++) { + const el = elements[idx]; + if (!el) continue; + for (const stmt of stmts) { + lines.push( + stmt === targetStmt + ? buildUnrollCallForElement(timelineVar, animation, el) + : substituteLoopIndex(stmt, indexVar, idx, script), + ); + } + } + return lines.join("\n "); +} + +/** + * Unroll the loop body, preserving every statement that is NOT the target tween. + * For each iteration, emit each non-target statement with the loop index + * substituted (e.g. `tl.set(items[i], …)` → `tl.set(items[0], …)`), and replace + * the target tween statement with that element's static `tl.to()` call. + * + * Returns null when a blanket overwrite is lossless (no sibling statements), and + * REFUSE_UNROLL when siblings exist but can't be safely reproduced — a non-`for` + * loop (no numeric index to splice), a statement we don't model, or an unsafe + * index use (shadowing / shorthand). Refusing no-ops the unroll, which is safe: + * the dynamic loop keeps rendering correctly, just un-flattened. + */ +function buildLoopUnrollPreserving( + script: string, + timelineVar: string, + animation: GsapAnimation, + elements: UnrollElement[], + loopNode: Node, + targetStmt: Node, +): string | null | typeof REFUSE_UNROLL { + const stmts = loopBodyStatements(loopNode); + if (!stmts || !stmts.includes(targetStmt)) return null; + const indexVar = loopIndexVarName(loopNode); + const strategy = unrollSiblingStrategy(loopNode, targetStmt, stmts, indexVar); + if (strategy === "blanket") return null; + if (strategy === "refuse" || !indexVar) return REFUSE_UNROLL; + return emitUnrolledLines(stmts, targetStmt, elements, timelineVar, animation, indexVar, script); +} + +/** + * Replace a dynamic loop that generates multiple tween calls with individual + * static `tl.to()` calls — one per element. Finds the loop containing the + * animation and replaces the loop with unrolled static calls, preserving every + * non-target statement in the loop body per iteration. + */ +export function unrollDynamicAnimations( + script: string, + animationId: string, + elements: UnrollElement[], +): string { + // An empty element list has no unrolled form — replacing the loop/statement + // with zero calls would silently delete the animation. No-op instead. + if (elements.length === 0) return script; + const parsed = parseGsapScriptAcornForWrite(script); + if (!parsed) return script; + const target = parsed.located.find((l) => l.id === animationId); + if (!target) return script; + + const ms = new MagicString(script); + const loopNode = findEnclosingLoopNode(target.call.ancestors); + if (loopNode) { + const targetStmt = findEnclosingExpressionStatement(target.call.ancestors); + const preserving = targetStmt + ? buildLoopUnrollPreserving( + script, + parsed.timelineVar, + target.animation, + elements, + loopNode, + targetStmt, + ) + : null; + // Siblings exist but can't be safely reproduced — leave the loop untouched + // rather than drop or corrupt them. The op no-ops (before === after). + if (preserving === REFUSE_UNROLL) return script; + // Fall back to the simple whole-body replacement when the body isn't a plain + // block of statements we can preserve. + const replacement = + preserving ?? buildUnrollReplacement(parsed.timelineVar, target.animation, elements); + ms.overwrite(loopNode.start as number, loopNode.end as number, replacement); + } else { + const stmt = findEnclosingExpressionStatement(target.call.ancestors); + if (!stmt) return script; + const replacement = buildUnrollReplacement(parsed.timelineVar, target.animation, elements); + ms.overwrite(stmt.start as number, stmt.end as number, replacement); + } + return ms.toString(); +} diff --git a/packages/core/src/parsers/gsapWriterParity.acorn.test.ts b/packages/parsers/src/gsapWriterParity.acorn.test.ts similarity index 100% rename from packages/core/src/parsers/gsapWriterParity.acorn.test.ts rename to packages/parsers/src/gsapWriterParity.acorn.test.ts diff --git a/packages/core/src/parsers/gsapWriterParity.corpus.test.ts b/packages/parsers/src/gsapWriterParity.corpus.test.ts similarity index 100% rename from packages/core/src/parsers/gsapWriterParity.corpus.test.ts rename to packages/parsers/src/gsapWriterParity.corpus.test.ts diff --git a/packages/core/src/parsers/hfIds.test.ts b/packages/parsers/src/hfIds.test.ts similarity index 100% rename from packages/core/src/parsers/hfIds.test.ts rename to packages/parsers/src/hfIds.test.ts diff --git a/packages/parsers/src/hfIds.ts b/packages/parsers/src/hfIds.ts new file mode 100644 index 0000000000..a7f2831c91 --- /dev/null +++ b/packages/parsers/src/hfIds.ts @@ -0,0 +1,132 @@ +/** + * Stable hf- element id minting (R1). Node-safe (linkedom only, not browser DOM). + * + * Two surfaces share these helpers: + * - ensureHfIds(html): node-id surface — mints data-hf-id on every element. + * - mintHfId(el, assigned): shared by htmlParser for clip ids. + * + * Hash is CONTENT ONLY (tag + sorted attrs + own text) — no sibling position, + * so inserting a non-identical sibling never shifts another element's id. + */ +import { parseHTML } from "linkedom"; + +// Non-editable / non-visual elements that should never receive a stable id. +export const EXCLUDED_TAGS = new Set([ + "script", + "style", + "template", + "meta", + "link", + "noscript", + "base", +]); + +// 32-bit FNV-1a. Pure, deterministic, no crypto, no Math.random. +function fnv1a(str: string): number { + let h = 0x811c9dc5; + for (let i = 0; i < str.length; i++) { + h ^= str.charCodeAt(i); + h = Math.imul(h, 0x01000193); + } + return h >>> 0; +} + +// 4 base-36 chars · 36^4 ≈ 1.68M ids per document. Birthday-paradox collision +// ≈ N²/(2·36^4): well under 1% per document after dup rehash at realistic +// clip-model sizes (≤ a few hundred elements). The dup-rehash in mintHfId +// resolves the rare collision; width is deliberately small for readable ids. +function toHfId(hash: number): string { + const s = (hash >>> 0).toString(36); + // Use suffix (most-avalanched bits) for better distribution within the 4-char window. + const four = s.length >= 4 ? s.slice(-4) : s.padStart(4, "0"); + return `hf-${four}`; +} + +// Element's own direct text (TEXT_NODE children), not descendants'. +function ownText(el: Element): string { + let text = ""; + el.childNodes.forEach((n) => { + if (n.nodeType === 3) text += (n as Text).nodeValue ?? ""; + }); + return text.trim(); +} + +function contentKey(el: Element): string { + // Exclude all data-hf-* attrs (ids, studio state) — they must not influence the hash. + // Use \x00 / \x01 separators (invalid in HTML attrs) to prevent ambiguous serialization. + const attrs = Array.from(el.attributes) + .filter((a) => !a.name.startsWith("data-hf-")) + .map((a) => `${a.name}\x00${a.value}`) + .sort() + .join("\x01"); + return `${el.tagName.toLowerCase()}|${attrs}|${ownText(el)}`; +} + +/** + * Collision tiebreak for byte-identical siblings: document-order dup counter + * (`hash(key#N)`). This IS order-dependent — two identical `` + * get different ids based on which comes first in the DOM. This is unavoidable: + * unique ids for byte-identical elements require a positional signal. + * + * Why this is safe in practice: once `ensureHfIds` write-back persists + * `data-hf-id` to source the attribute is physically bound to its element. + * Reordering identical siblings carries the attribute along → zero + * order-dependence post-persist. `ensureHfIds` skips pinned elements + * (`if (el.getAttribute("data-hf-id")) continue`), so normal operation + * never re-exposes the ordering after first persist. + */ +// WIRE CONTRACT: id minting is content-keyed (FNV1a of innerHTML + tag). R7's +// preview route relies on mintHfId producing identical ids across mint contexts +// (disk-persist pass vs. in-memory bundle pass) — see preview.test.ts +// "bundle returning untagged HTML gets same ids as disk". Any change that adds +// positional, session, or random input to the hash breaks that invariant and +// makes hf- ids diverge between disk and served HTML, silently corrupting +// drag-to-edit targeting. +export function mintHfId(el: Element, assigned: Set): string { + const key = contentKey(el); + let id = toHfId(fnv1a(key)); + let dup = 0; + while (assigned.has(id)) { + dup += 1; + // Graceful fallback instead of a hard throw: rehashing only fails to find a + // free 4-char slot in a pathological document (~1.6M identical elements). + // Rather than crash the whole parse, widen the id with the dup counter — + // still deterministic and unique, just longer than the 4-char norm. + if (dup > 10000) { + id = `hf-${(fnv1a(key) >>> 0).toString(36)}-${dup}`; + break; + } + id = toHfId(fnv1a(`${key}#${dup}`)); + } + assigned.add(id); + return id; +} + +export function ensureHfIds(html: string): string { + // Mirror parseSourceDocument's fragment-wrapping so bare fragments don't land + // outside in linkedom, which would cause body.querySelectorAll to return []. + const hasDocumentShell = /]/i.test(html); + const wrapped = !hasDocumentShell; + const { document } = wrapped + ? parseHTML(`${html}`) + : parseHTML(html); + const body = document.body; + if (!body) return html; + + const assigned = new Set(); + // Seed with already-present ids (pin) so fresh mints never collide with them. + // Scope to to match the mint walk below — a stray data-hf-id in + // must not pin an id into the set that a body element would then be bumped off. + for (const el of Array.from(body.querySelectorAll("[data-hf-id]"))) { + const existing = el.getAttribute("data-hf-id"); + if (existing) assigned.add(existing); + } + + for (const el of Array.from(body.querySelectorAll("*"))) { + if (EXCLUDED_TAGS.has(el.tagName.toLowerCase())) continue; + if (el.getAttribute("data-hf-id")) continue; // pinned + el.setAttribute("data-hf-id", mintHfId(el, assigned)); + } + + return wrapped ? document.body.innerHTML || "" : document.toString(); +} diff --git a/packages/core/src/parsers/htmlParser.roundtrip.test.ts b/packages/parsers/src/htmlParser.roundtrip.test.ts similarity index 98% rename from packages/core/src/parsers/htmlParser.roundtrip.test.ts rename to packages/parsers/src/htmlParser.roundtrip.test.ts index 975ee6db33..0c5f14ce9d 100644 --- a/packages/core/src/parsers/htmlParser.roundtrip.test.ts +++ b/packages/parsers/src/htmlParser.roundtrip.test.ts @@ -10,7 +10,7 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { parseHtml } from "./htmlParser.js"; import { maxEndTime, serialize } from "./test-utils.js"; -import { generateHyperframesHtml } from "../generators/hyperframes.js"; +import { generateHyperframesHtml } from "@hyperframes/core"; describe("T1 — parse→serialize round-trip (DOM/timing)", () => { it("preserves element count and ids through one round-trip", () => { diff --git a/packages/core/src/parsers/htmlParser.test.ts b/packages/parsers/src/htmlParser.test.ts similarity index 100% rename from packages/core/src/parsers/htmlParser.test.ts rename to packages/parsers/src/htmlParser.test.ts diff --git a/packages/parsers/src/htmlParser.ts b/packages/parsers/src/htmlParser.ts new file mode 100644 index 0000000000..437778ae71 --- /dev/null +++ b/packages/parsers/src/htmlParser.ts @@ -0,0 +1,861 @@ +import type { + TimelineElement, + TimelineElementType, + TimelineMediaElement, + TimelineTextElement, + TimelineCompositionElement, + CanvasResolution, + Keyframe, + KeyframeProperties, + StageZoomKeyframe, + CompositionVariable, + ValidationResult, +} from "./types.js"; +import { validateCompositionGsap } from "./gsapSerialize"; +import { ensureHfIds } from "./hfIds.js"; +import { parseGsapScriptAcornForWrite } from "./gsapParserAcorn.js"; +import { queryByAttr } from "./utils/cssSelector.js"; +import { removeAnimationFromScript } from "./gsapWriterAcorn.js"; + +const MEDIA_TYPES = new Set(["video", "image", "audio"]); + +export interface ParsedHtml { + elements: TimelineElement[]; + gsapScript: string | null; + styles: string | null; + resolution: CanvasResolution; + keyframes: Record; + stageZoomKeyframes: StageZoomKeyframe[]; +} + +function getElementType(el: Element): TimelineElementType | null { + const tag = el.tagName.toLowerCase(); + if (tag === "video") return "video"; + if (tag === "img") return "image"; + if (tag === "audio") return "audio"; + // Check for explicit data-type attribute first + const dataType = el.getAttribute("data-type"); + if (dataType === "composition") return "composition"; + if (dataType === "text") return "text"; + // Fall back to tag-based detection for backwards compatibility + if ( + tag === "div" || + tag === "p" || + tag === "h1" || + tag === "h2" || + tag === "h3" || + tag === "span" + ) { + return "text"; + } + return null; +} + +function getElementName(el: Element): string { + const dataName = el.getAttribute("data-name"); + if (dataName) return dataName; + + const type = getElementType(el); + if (type === "text") { + const text = el.textContent?.trim().slice(0, 30) || "Text"; + return text.length === 30 ? text + "..." : text; + } + + const src = el.getAttribute("src"); + if (src) { + const filename = src.split("/").pop() || src; + return filename.split("?")[0] ?? filename; + } + + return el.id || el.className?.toString().split(" ")[0] || "Element"; +} + +function getZIndex(el: Element): number { + const dataLayer = el.getAttribute("data-layer"); + if (dataLayer) return parseInt(dataLayer, 10) || 0; + + const style = (el as HTMLElement).style?.zIndex; + if (style) return parseInt(style, 10) || 0; + + return 0; +} + +function parseResolutionFromCss(doc: Document, cssText: string | null): CanvasResolution { + const stage = doc.getElementById("stage") || doc.querySelector("#stage"); + if (stage) { + const inlineStyle = (stage as HTMLElement).style; + if (inlineStyle?.width && inlineStyle?.height) { + const w = parseInt(inlineStyle.width, 10); + const h = parseInt(inlineStyle.height, 10); + if (w && h) { + return resolveResolutionFromDimensions(w, h); + } + } + } + + if (cssText) { + const stageMatch = cssText.match( + /#stage\s*\{[^}]*width:\s*(\d+)px[^}]*height:\s*(\d+)px[^}]*\}/, + ); + if (stageMatch) { + const w = parseInt(stageMatch[1] ?? "", 10); + const h = parseInt(stageMatch[2] ?? "", 10); + return resolveResolutionFromDimensions(w, h); + } + const stageMatchReverse = cssText.match( + /#stage\s*\{[^}]*height:\s*(\d+)px[^}]*width:\s*(\d+)px[^}]*\}/, + ); + if (stageMatchReverse) { + const h = parseInt(stageMatchReverse[1] ?? "", 10); + const w = parseInt(stageMatchReverse[2] ?? "", 10); + return resolveResolutionFromDimensions(w, h); + } + } + + return "portrait"; +} + +function parseResolutionFromHtml(doc: Document): CanvasResolution | null { + const htmlEl = doc.documentElement; + const resolutionAttr = htmlEl.getAttribute("data-resolution"); + if ( + resolutionAttr === "landscape" || + resolutionAttr === "portrait" || + resolutionAttr === "landscape-4k" || + resolutionAttr === "portrait-4k" || + resolutionAttr === "square" || + resolutionAttr === "square-4k" + ) { + return resolutionAttr; + } + + const widthAttr = htmlEl.getAttribute("data-composition-width"); + const heightAttr = htmlEl.getAttribute("data-composition-height"); + if (widthAttr && heightAttr) { + const width = parseInt(widthAttr, 10); + const height = parseInt(heightAttr, 10); + if (width && height) { + return resolveResolutionFromDimensions(width, height); + } + } + + return null; +} + +const UHD_SQUARE_MIN = 2160; +const UHD_RECT_MIN = 3840; + +function resolveResolutionFromDimensions(width: number, height: number): CanvasResolution { + const longSide = Math.max(width, height); + if (width === height) { + return longSide >= UHD_SQUARE_MIN ? "square-4k" : "square"; + } + const isLandscape = width > height; + const isUhd = longSide >= UHD_RECT_MIN; + if (isLandscape) return isUhd ? "landscape-4k" : "landscape"; + return isUhd ? "portrait-4k" : "portrait"; +} + +export function parseHtml(html: string): ParsedHtml { + const withIds = ensureHfIds(html); + const parser = new DOMParser(); + const doc = parser.parseFromString(withIds, "text/html"); + + const elements: TimelineElement[] = []; + const keyframes: Record = {}; + let idCounter = 0; + + const htmlEl = doc.documentElement; + const customStylesAttr = htmlEl.getAttribute("data-custom-styles"); + let customStyles: string | null = null; + if (customStylesAttr) { + try { + customStyles = JSON.parse(customStylesAttr); + } catch { + customStyles = customStylesAttr; + } + } + + const timedElements = doc.querySelectorAll("[data-start]"); + + timedElements.forEach((el) => { + const type = getElementType(el); + if (!type) return; + + const start = parseFloat(el.getAttribute("data-start") || "0"); + const dataEnd = el.getAttribute("data-end"); + + let duration: number; + if (dataEnd) { + duration = Math.max(0, parseFloat(dataEnd) - start); + } else { + duration = 5; + } + + // R1: stable hf- id minted by ensureHfIds above; clips just read it. + // Legacy/migration note: ensureHfIds pins a pre-existing `data-hf-id`, and + // the generator emits `data-hf-id="${element.id}"`. So a clip authored + // before R1 with `id="my-title"` round-trips as `data-hf-id="my-title"` — + // a non-`hf-`-shaped but still stable, exact-match handle. This is safe + // indefinitely: targeting uses exact `[data-hf-id="…"]` match (it does not + // require the hf- prefix). ensureHfIds skips elements that already carry + // data-hf-id, so legacy values are NOT re-minted automatically — they + // persist until the user re-saves the composition through Studio. Not a bug. + const id = el.getAttribute("data-hf-id") || el.id || `element-${++idCounter}`; + const name = getElementName(el); + const zIndex = getZIndex(el); + + // Parse data-keyframes attribute if present + const keyframesAttr = el.getAttribute("data-keyframes"); + if (keyframesAttr) { + try { + const parsedKeyframes = JSON.parse(keyframesAttr); + if (Array.isArray(parsedKeyframes) && parsedKeyframes.length > 0) { + keyframes[id] = parsedKeyframes; + } + } catch { + // skip invalid keyframes + } + } + + // Parse transform properties (x, y, scale, opacity) + const xAttr = el.getAttribute("data-x"); + const yAttr = el.getAttribute("data-y"); + const scaleAttr = el.getAttribute("data-scale"); + const opacityAttr = el.getAttribute("data-opacity"); + const x = xAttr ? parseFloat(xAttr) : undefined; + const y = yAttr ? parseFloat(yAttr) : undefined; + const scale = scaleAttr ? parseFloat(scaleAttr) : undefined; + const opacity = opacityAttr ? parseFloat(opacityAttr) : undefined; + + if (type === "text") { + const textEl = el.firstElementChild; + const content = textEl?.textContent || name; + const color = el.getAttribute("data-color") || undefined; + const fontSizeAttr = el.getAttribute("data-font-size"); + const fontSize = fontSizeAttr ? parseInt(fontSizeAttr, 10) : undefined; + const fontWeightAttr = el.getAttribute("data-font-weight"); + const fontWeight = fontWeightAttr ? parseInt(fontWeightAttr, 10) : undefined; + const fontFamily = el.getAttribute("data-font-family") || undefined; + const textShadowAttr = el.getAttribute("data-text-shadow"); + const textShadow = textShadowAttr === "false" ? false : undefined; + + // Parse outline properties + const textOutlineAttr = el.getAttribute("data-text-outline"); + const textOutline = textOutlineAttr === "true" ? true : undefined; + const textOutlineColor = el.getAttribute("data-text-outline-color") || undefined; + const textOutlineWidthAttr = el.getAttribute("data-text-outline-width"); + const textOutlineWidth = textOutlineWidthAttr + ? parseInt(textOutlineWidthAttr, 10) + : undefined; + + // Parse highlight properties + const textHighlightAttr = el.getAttribute("data-text-highlight"); + const textHighlight = textHighlightAttr === "true" ? true : undefined; + const textHighlightColor = el.getAttribute("data-text-highlight-color") || undefined; + const textHighlightPaddingAttr = el.getAttribute("data-text-highlight-padding"); + const textHighlightPadding = textHighlightPaddingAttr + ? parseInt(textHighlightPaddingAttr, 10) + : undefined; + const textHighlightRadiusAttr = el.getAttribute("data-text-highlight-radius"); + const textHighlightRadius = textHighlightRadiusAttr + ? parseInt(textHighlightRadiusAttr, 10) + : undefined; + + const textElement: TimelineTextElement = { + id, + type: "text", + name, + content, + startTime: start, + duration, + zIndex, + x, + y, + scale, + opacity, + color, + fontSize, + fontWeight, + fontFamily, + textShadow, + textOutline, + textOutlineColor, + textOutlineWidth, + textHighlight, + textHighlightColor, + textHighlightPadding, + textHighlightRadius, + }; + elements.push(textElement); + } else if (type === "composition") { + // Composition is a div container with iframe inside + const iframe = el.querySelector("iframe"); + const src = iframe?.getAttribute("src") || el.getAttribute("src") || ""; + const compositionId = el.getAttribute("data-composition-id") || ""; + const sourceDurationAttr = el.getAttribute("data-source-duration"); + const sourceDuration = sourceDurationAttr ? parseFloat(sourceDurationAttr) : undefined; + const sourceWidthAttr = el.getAttribute("data-source-width"); + const sourceWidth = sourceWidthAttr ? parseInt(sourceWidthAttr, 10) : undefined; + const sourceHeightAttr = el.getAttribute("data-source-height"); + const sourceHeight = sourceHeightAttr ? parseInt(sourceHeightAttr, 10) : undefined; + + // Parse variable values if present + const variableValuesAttr = el.getAttribute("data-variable-values"); + let variableValues: Record | undefined; + if (variableValuesAttr) { + try { + variableValues = JSON.parse(variableValuesAttr); + } catch { + // skip invalid variable values + } + } + + const compositionElement: TimelineCompositionElement = { + id, + type: "composition", + name, + src, + compositionId, + startTime: start, + duration, + zIndex, + x, + y, + scale, + opacity, + sourceDuration, + sourceWidth, + sourceHeight, + variableValues, + }; + elements.push(compositionElement); + } else { + if (!MEDIA_TYPES.has(type)) return; + + const src = el.getAttribute("src") || ""; + const mediaStartTimeAttr = el.getAttribute("data-media-start"); + const mediaStartTime = mediaStartTimeAttr ? parseFloat(mediaStartTimeAttr) : undefined; + const sourceDurationAttr = el.getAttribute("data-source-duration"); + const sourceDuration = sourceDurationAttr ? parseFloat(sourceDurationAttr) : undefined; + const isArollAttr = el.getAttribute("data-aroll"); + const isAroll = isArollAttr === "true" ? true : undefined; + const volumeAttr = el.getAttribute("data-volume"); + const volume = volumeAttr ? parseFloat(volumeAttr) : undefined; + const hasAudioAttr = el.getAttribute("data-has-audio"); + const hasAudio = hasAudioAttr === "true" ? true : undefined; + + const mediaElement: TimelineMediaElement = { + id, + type: type as "video" | "image" | "audio", + name, + src, + startTime: start, + duration, + zIndex, + x, + y, + scale, + opacity, + mediaStartTime, + sourceDuration, + isAroll, + volume, + hasAudio, + }; + elements.push(mediaElement); + } + }); + + const scriptTags = doc.querySelectorAll("script"); + let gsapScript: string | null = null; + + for (const script of scriptTags) { + const src = script.getAttribute("src"); + if (src && src.includes("gsap")) continue; + + const content = script.textContent?.trim(); + if (content && (content.includes("gsap") || content.includes("timeline"))) { + gsapScript = content; + break; + } + } + + // Normalize keyframes (clamp negative time, convert absolute -> relative if detected) + for (const element of elements) { + const elementKeyframes = keyframes[element.id]; + if (!elementKeyframes || elementKeyframes.length === 0) continue; + + const baseX = element.x ?? 0; + const baseY = element.y ?? 0; + const baseScale = + element.type === "video" || element.type === "image" || element.type === "composition" + ? ((element as TimelineMediaElement | TimelineCompositionElement).scale ?? 1) + : 1; + + keyframes[element.id] = normalizeKeyframes(elementKeyframes, baseX, baseY, baseScale); + } + + const styleTags = doc.querySelectorAll("style"); + const allStyles = + Array.from(styleTags) + .map((s) => s.textContent?.trim()) + .filter(Boolean) + .join("\n\n") || null; + + const customStyleTags = Array.from(styleTags).filter( + (s) => s.getAttribute("data-hf-custom") === "true", + ); + const customStylesFromTags = + customStyleTags + .map((s) => s.textContent?.trim()) + .filter(Boolean) + .join("\n\n") || null; + + const styles = customStyles ?? customStylesFromTags ?? null; + + const resolution = parseResolutionFromHtml(doc) ?? parseResolutionFromCss(doc, allStyles); + + // Parse stage zoom keyframes from zoom container + const stageZoomKeyframes = parseStageZoomKeyframes(doc); + + return { + elements, + gsapScript, + styles, + resolution, + keyframes, + stageZoomKeyframes, + }; +} + +function parseStageZoomKeyframes(doc: Document): StageZoomKeyframe[] { + const zoomContainer = doc.getElementById("stage-zoom-container"); + if (!zoomContainer) { + return []; + } + + const zoomKeyframesAttr = zoomContainer.getAttribute("data-zoom-keyframes"); + if (!zoomKeyframesAttr) { + return []; + } + + try { + const parsed = JSON.parse(zoomKeyframesAttr); + if (Array.isArray(parsed)) { + return parsed.filter( + (kf): kf is StageZoomKeyframe => + typeof kf === "object" && + kf !== null && + typeof kf.id === "string" && + typeof kf.time === "number" && + typeof kf.zoom === "object" && + kf.zoom !== null && + typeof kf.zoom.scale === "number" && + typeof kf.zoom.focusX === "number" && + typeof kf.zoom.focusY === "number", + ); + } + } catch { + // skip invalid zoom keyframes + } + + return []; +} + +function normalizeKeyframes( + keyframes: Keyframe[], + baseX: number, + baseY: number, + baseScale: number, +): Keyframe[] { + const timeEpsilon = 0.001; + const valueEpsilon = 0.00001; + + const hasBaseCheck = (value: number | undefined, base: number): boolean => + value !== undefined && Math.abs(value - base) <= valueEpsilon && Math.abs(base) > valueEpsilon; + + const timeZeroKeyframes = keyframes.filter((kf) => Math.abs(kf.time) <= timeEpsilon); + + const treatAsAbsolute = timeZeroKeyframes.some((kf) => { + const props = kf.properties || {}; + if ( + hasBaseCheck(props.x, baseX) || + hasBaseCheck(props.y, baseY) || + (baseScale !== 1 && hasBaseCheck(props.scale, baseScale)) + ) { + return true; + } + return false; + }); + + return keyframes.map((kf) => { + const normalizedProps: Partial = {}; + for (const [key, value] of Object.entries(kf.properties || {})) { + if (typeof value !== "number") continue; + if (treatAsAbsolute && key === "x") { + normalizedProps.x = value - baseX; + } else if (treatAsAbsolute && key === "y") { + normalizedProps.y = value - baseY; + } else if (treatAsAbsolute && key === "scale") { + normalizedProps.scale = baseScale !== 0 ? value / baseScale : value; + } else { + (normalizedProps as Record)[key] = value; + } + } + + return { + ...kf, + time: Math.max(0, kf.time), + properties: normalizedProps, + }; + }); +} + +export function updateElementInHtml( + html: string, + elementId: string, + updates: Partial, +): string { + const parser = new DOMParser(); + const doc = parser.parseFromString(html, "text/html"); + + const el = doc.getElementById(elementId) || queryByAttr(doc, "data-name", elementId); + if (!el) return html; + + if (updates.startTime !== undefined) { + el.setAttribute("data-start", String(updates.startTime)); + if (el.hasAttribute("data-end") && updates.duration !== undefined) { + el.setAttribute("data-end", String(updates.startTime + updates.duration)); + } + } + + if (updates.duration !== undefined) { + const start = parseFloat(el.getAttribute("data-start") || "0"); + el.setAttribute("data-end", String(start + updates.duration)); + el.removeAttribute("data-duration"); // Clean up legacy + } + + if (updates.name !== undefined) { + el.setAttribute("data-name", updates.name); + } + + if (updates.zIndex !== undefined) { + el.setAttribute("data-layer", String(updates.zIndex)); + } + + // Handle media-specific property + if ("src" in updates && updates.src !== undefined) { + el.setAttribute("src", updates.src); + } + + // Handle text-specific properties + if ("content" in updates && updates.content !== undefined) { + const textEl = el.firstElementChild; + if (textEl) { + textEl.textContent = updates.content; + } + } + + if ("color" in updates && updates.color !== undefined) { + el.setAttribute("data-color", updates.color); + } + + if ("fontSize" in updates && updates.fontSize !== undefined) { + el.setAttribute("data-font-size", String(updates.fontSize)); + } + + if ("textShadow" in updates) { + if (updates.textShadow === false) { + el.setAttribute("data-text-shadow", "false"); + } else { + el.removeAttribute("data-text-shadow"); + } + } + + // Handle volume property for audio/video + if ("volume" in updates) { + if (updates.volume !== undefined && updates.volume !== 1) { + el.setAttribute("data-volume", String(updates.volume)); + } else { + el.removeAttribute("data-volume"); + } + } + + // Handle hasAudio property for videos + if ("hasAudio" in updates) { + if (updates.hasAudio === true) { + el.setAttribute("data-has-audio", "true"); + } else { + el.removeAttribute("data-has-audio"); + } + } + + return "\n" + doc.documentElement.outerHTML; +} + +export function addElementToHtml( + html: string, + element: Omit & { id?: string }, +): { html: string; id: string } { + const parser = new DOMParser(); + const doc = parser.parseFromString(html, "text/html"); + + // Prefer zoom container, fall back to stage, then container, then body + const container = + doc.querySelector("#stage-zoom-container") || + doc.querySelector(".container") || + doc.querySelector("#stage") || + doc.body; + + const id = element.id || `element-${Date.now()}`; + + let newEl: Element; + + function applyMediaAttrs(el: Element, mediaEl: TimelineMediaElement): void { + if (mediaEl.src) el.setAttribute("src", mediaEl.src); + if (mediaEl.volume !== undefined && mediaEl.volume !== 1) { + el.setAttribute("data-volume", String(mediaEl.volume)); + } + } + + switch (element.type) { + case "video": { + const mediaEl = element as TimelineMediaElement; + newEl = doc.createElement("video"); + newEl.setAttribute("muted", ""); + newEl.setAttribute("playsinline", ""); + applyMediaAttrs(newEl, mediaEl); + if (mediaEl.hasAudio) { + newEl.setAttribute("data-has-audio", "true"); + } + break; + } + case "image": { + const mediaEl = element as TimelineMediaElement; + newEl = doc.createElement("img"); + if (mediaEl.src) newEl.setAttribute("src", mediaEl.src); + newEl.setAttribute("alt", element.name); + break; + } + case "audio": { + const mediaEl = element as TimelineMediaElement; + newEl = doc.createElement("audio"); + applyMediaAttrs(newEl, mediaEl); + break; + } + case "text": + default: { + const textEl = element as TimelineTextElement; + newEl = doc.createElement("div"); + const textContent = doc.createElement("div"); + textContent.textContent = textEl.content || element.name; + newEl.appendChild(textContent); + if (textEl.color) { + newEl.setAttribute("data-color", textEl.color); + } + if (textEl.fontSize) { + newEl.setAttribute("data-font-size", String(textEl.fontSize)); + } + break; + } + } + + newEl.id = id; + newEl.setAttribute("data-start", String(element.startTime)); + newEl.setAttribute("data-end", String(element.startTime + element.duration)); + newEl.setAttribute("data-layer", String(element.zIndex)); + newEl.setAttribute("data-name", element.name); + + container.appendChild(newEl); + + return { + html: "\n" + doc.documentElement.outerHTML, + id, + }; +} + +function selectorTargetsId(selector: string, id: string): boolean { + return ( + selector === `#${id}` || + selector === `[data-hf-id="${id}"]` || + selector === `[data-hf-id='${id}']` + ); +} + +function stripGsapForId(script: string, elementId: string): string { + // Re-parse after every removal. Animation ids are count-based (positional), so + // removing one tween renumbers the survivors — ids captured from a single + // up-front parse go stale and silently no-op, orphaning later tweens on the + // now-deleted element. Always remove the FIRST still-matching animation in a + // freshly-parsed script until none remain. + let current = script; + for (;;) { + const parsed = parseGsapScriptAcornForWrite(current); + if (!parsed) return current; + const match = parsed.located.find((l) => + selectorTargetsId(l.animation.targetSelector, elementId), + ); + if (!match) return current; + const updated = removeAnimationFromScript(current, match.id); + // Guard against a non-removing match (would otherwise loop forever). + if (updated === current) return current; + current = updated; + } +} + +function cascadeRemoveGsapById(doc: Document, elementId: string): void { + for (const script of Array.from(doc.querySelectorAll("script"))) { + const text = script.textContent ?? ""; + if (!text.includes("gsap") && !text.includes("ScrollTrigger")) continue; + const updated = stripGsapForId(text, elementId); + if (updated !== text) script.textContent = updated; + } +} + +export function removeElementFromHtml(html: string, elementId: string): string { + const parser = new DOMParser(); + const doc = parser.parseFromString(html, "text/html"); + doc.getElementById(elementId)?.remove(); + cascadeRemoveGsapById(doc, elementId); + return "\n" + doc.documentElement.outerHTML; +} + +export interface CompositionMetadata { + compositionId: string | null; + compositionDuration: number | null; + variables: CompositionVariable[]; +} + +export function extractCompositionMetadata(html: string): CompositionMetadata { + const parser = new DOMParser(); + const doc = parser.parseFromString(html, "text/html"); + const htmlEl = doc.documentElement; + + const compositionId = htmlEl.getAttribute("data-composition-id"); + const durationStr = htmlEl.getAttribute("data-composition-duration"); + const compositionDuration = durationStr ? parseFloat(durationStr) : null; + + const variables = parseCompositionVariables(htmlEl); + + return { + compositionId, + compositionDuration: + compositionDuration && isFinite(compositionDuration) ? compositionDuration : null, + variables, + }; +} + +function parseCompositionVariables(htmlEl: Element): CompositionVariable[] { + const variablesAttr = htmlEl.getAttribute("data-composition-variables"); + if (!variablesAttr) { + return []; + } + + try { + const parsed = JSON.parse(variablesAttr); + if (!Array.isArray(parsed)) { + return []; + } + + return parsed.filter((v): v is CompositionVariable => { + if (typeof v !== "object" || v === null) return false; + if (typeof v.id !== "string" || typeof v.label !== "string") return false; + if (!["string", "number", "color", "boolean", "enum", "font", "image"].includes(v.type)) + return false; + + switch (v.type) { + case "string": + return typeof v.default === "string"; + case "number": + return typeof v.default === "number"; + case "color": + return typeof v.default === "string"; + case "boolean": + return typeof v.default === "boolean"; + case "enum": + return typeof v.default === "string" && Array.isArray(v.options); + case "font": + // default is the font-family name string; extra metadata fields are optional + return typeof v.default === "string"; + case "image": + // default is the fallback image URL string; extra metadata fields are optional + return typeof v.default === "string"; + default: + return false; + } + }); + } catch { + return []; + } +} + +export function validateCompositionHtml(html: string): ValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + + const parser = new DOMParser(); + const doc = parser.parseFromString(html, "text/html"); + const htmlEl = doc.documentElement; + + const compositionId = htmlEl.getAttribute("data-composition-id"); + if (!compositionId) { + errors.push("Missing data-composition-id attribute on element"); + } + + const durationStr = htmlEl.getAttribute("data-composition-duration"); + if (!durationStr) { + errors.push("Missing data-composition-duration attribute on element"); + } else { + const duration = parseFloat(durationStr); + if (!isFinite(duration) || duration <= 0) { + errors.push("data-composition-duration must be a positive finite number"); + } + } + + const stage = doc.getElementById("stage"); + if (!stage) { + errors.push("Missing #stage element"); + } + + if (/\son\w+\s*=/i.test(html)) { + errors.push("Inline event handlers (onclick, onload, etc.) not allowed"); + } + + if (/javascript\s*:/i.test(html)) { + errors.push("javascript: URLs not allowed"); + } + + const scripts = doc.querySelectorAll("script"); + if (scripts.length > 2) { + warnings.push("Multiple script tags detected - only GSAP CDN and main script expected"); + } + + const gsapScript = extractGsapScript(doc); + if (gsapScript) { + const gsapValidation = validateCompositionGsap(gsapScript); + errors.push(...gsapValidation.errors); + warnings.push(...gsapValidation.warnings); + } + + return { + valid: errors.length === 0, + errors, + warnings, + }; +} + +function extractGsapScript(doc: Document): string | null { + const scripts = doc.querySelectorAll("script"); + for (const script of scripts) { + const content = script.textContent || ""; + if ( + content.includes("gsap.timeline") || + content.includes(".set(") || + content.includes(".to(") + ) { + return content; + } + } + return null; +} diff --git a/packages/parsers/src/index.ts b/packages/parsers/src/index.ts index a0c4ebf250..f7498a9170 100644 --- a/packages/parsers/src/index.ts +++ b/packages/parsers/src/index.ts @@ -1 +1,6 @@ export * from "./types.js"; +export * from "./gsapParserExports.js"; +export * from "./htmlParser.js"; +export * from "./hfIds.js"; +export { unrollComputedTimeline } from "./gsapUnroll.js"; +export { queryByAttr } from "./utils/cssSelector.js"; diff --git a/packages/core/src/parsers/springEase.test.ts b/packages/parsers/src/springEase.test.ts similarity index 100% rename from packages/core/src/parsers/springEase.test.ts rename to packages/parsers/src/springEase.test.ts diff --git a/packages/parsers/src/springEase.ts b/packages/parsers/src/springEase.ts new file mode 100644 index 0000000000..3d4fccbb22 --- /dev/null +++ b/packages/parsers/src/springEase.ts @@ -0,0 +1,88 @@ +/** + * Damped harmonic oscillator solver for GSAP CustomEase spring curves. + * + * Generates an SVG path data string compatible with `CustomEase.create(id, data)`. + * The solver supports underdamped (bouncy), critically damped, and overdamped + * spring configurations. Output is normalized to x ∈ [0,1] with y starting at 0 + * and settling to 1. + */ + +export interface SpringPreset { + name: string; + label: string; + mass: number; + stiffness: number; + damping: number; +} + +export const SPRING_PRESETS: SpringPreset[] = [ + { name: "spring-gentle", label: "Gentle", mass: 1, stiffness: 100, damping: 15 }, + { name: "spring-bouncy", label: "Bouncy", mass: 1, stiffness: 180, damping: 12 }, + { name: "spring-stiff", label: "Stiff", mass: 1, stiffness: 300, damping: 20 }, + { name: "spring-wobbly", label: "Wobbly", mass: 1, stiffness: 120, damping: 8 }, + { name: "spring-heavy", label: "Heavy", mass: 3, stiffness: 200, damping: 20 }, +]; + +/** + * Solve a damped harmonic oscillator and return a GSAP CustomEase data string. + * + * The output is an SVG path (`M0,0 L... L...`) that CustomEase.create() accepts. + * The curve is normalized so x spans [0,1] and the spring settles at y = 1. + * + * @param mass - Spring mass (> 0) + * @param stiffness - Spring stiffness constant (> 0) + * @param damping - Damping coefficient (> 0) + * @param steps - Number of sample points (default 120) + */ +export function generateSpringEaseData( + mass: number, + stiffness: number, + damping: number, + steps = 120, +): string { + const w0 = Math.sqrt(stiffness / mass); + const zeta = damping / (2 * Math.sqrt(stiffness * mass)); + + // Determine simulation duration: time until oscillation settles within threshold of 1.0. + // Underdamped: ~5 time constants. Critically/overdamped: characteristic decay time. + let settleDuration: number; + if (zeta < 1) { + settleDuration = Math.min(5 / (zeta * w0), 10); + } else { + const decayRate = zeta * w0 - w0 * Math.sqrt(zeta * zeta - 1); + settleDuration = Math.min(4 / Math.max(decayRate, 0.01), 10); + } + const simDuration = Math.max(settleDuration, 1); + + const segments: string[] = ["M0,0"]; + + for (let i = 1; i <= steps; i++) { + const t = i / steps; + const simT = t * simDuration; + let value: number; + + if (zeta < 1) { + // Underdamped — oscillates before settling + const wd = w0 * Math.sqrt(1 - zeta * zeta); + value = + 1 - + Math.exp(-zeta * w0 * simT) * + (Math.cos(wd * simT) + ((zeta * w0) / wd) * Math.sin(wd * simT)); + } else if (zeta === 1) { + // Critically damped — fastest approach without oscillation + value = 1 - (1 + w0 * simT) * Math.exp(-w0 * simT); + } else { + // Overdamped — slow exponential approach + const s1 = -w0 * (zeta - Math.sqrt(zeta * zeta - 1)); + const s2 = -w0 * (zeta + Math.sqrt(zeta * zeta - 1)); + value = 1 + (s1 * Math.exp(s2 * simT) - s2 * Math.exp(s1 * simT)) / (s2 - s1); + } + + segments.push(`${t.toFixed(4)},${value.toFixed(4)}`); + } + + // Force exact endpoint + segments[segments.length - 1] = "1,1"; + + return `${segments[0]} L${segments.slice(1).join(" ")}`; +} diff --git a/packages/core/src/parsers/stableIds.test.ts b/packages/parsers/src/stableIds.test.ts similarity index 100% rename from packages/core/src/parsers/stableIds.test.ts rename to packages/parsers/src/stableIds.test.ts diff --git a/packages/core/src/parsers/test-utils.ts b/packages/parsers/src/test-utils.ts similarity index 93% rename from packages/core/src/parsers/test-utils.ts rename to packages/parsers/src/test-utils.ts index 568b550040..ff6406795d 100644 --- a/packages/core/src/parsers/test-utils.ts +++ b/packages/parsers/src/test-utils.ts @@ -4,7 +4,7 @@ * * Not part of the public package exports — consumed only by *.test.ts files. */ -import { generateHyperframesHtml } from "../generators/hyperframes.js"; +import { generateHyperframesHtml } from "@hyperframes/core"; import type { ParsedHtml } from "./htmlParser.js"; export function maxEndTime(elements: ParsedHtml["elements"]): number { diff --git a/packages/parsers/src/utils/cssSelector.ts b/packages/parsers/src/utils/cssSelector.ts new file mode 100644 index 0000000000..df58c28c3c --- /dev/null +++ b/packages/parsers/src/utils/cssSelector.ts @@ -0,0 +1,14 @@ +// ponytail: queries DOM by exact attribute match without interpolating +// the value into a selector string — zero injection surface. +export function queryByAttr( + root: ParentNode, + attr: string, + value: string, + tag?: string, +): Element | null { + const selector = tag ? `${tag}[${attr}]` : `[${attr}]`; + for (const el of root.querySelectorAll(selector)) { + if (el.getAttribute(attr) === value) return el; + } + return null; +} diff --git a/packages/parsers/tsup.config.ts b/packages/parsers/tsup.config.ts index 3cae454c5a..3fee07ef05 100644 --- a/packages/parsers/tsup.config.ts +++ b/packages/parsers/tsup.config.ts @@ -9,6 +9,7 @@ export default defineConfig({ gsapConstants: "src/gsapConstants.ts", springEase: "src/springEase.ts", hfIds: "src/hfIds.ts", + gsapParser: "src/gsapParser.ts", }, format: ["esm"], outDir: "dist", From 0621a37a244ed21545f3262dc57b2901ed4d939a Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 26 Jun 2026 18:15:45 -0400 Subject: [PATCH 04/11] refactor: slim @hyperframes/core, depend on @hyperframes/parsers --- .fallowrc.jsonc | 5 +++++ packages/core/src/generators/hyperframes.ts | 4 ++-- packages/core/src/index.ts | 8 ++++---- packages/core/src/parsers/htmlParser.ts | 10 ---------- 4 files changed, 11 insertions(+), 16 deletions(-) delete mode 100644 packages/core/src/parsers/htmlParser.ts diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 28ac1ee485..29a89c925c 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -253,6 +253,11 @@ // gsapParser.ts: recast/babel GSAP writer — intentional duplication between // recast and acorn parallel implementations (pre-existing, moved from core). "packages/parsers/src/gsapParser.ts", + // hfIds.ts: 7-line clone with sdk/engine/mutate.ts — pre-existing duplication + // from when hfIds lived in packages/core/src/parsers/. Moving the file to the + // new package makes fallow see it as a fresh finding; the underlying clone + // predates this refactor. + "packages/parsers/src/hfIds.ts", // Parser test files: parallel arrange/act/assert test cases — pre-existing // duplication moved from packages/core/src/parsers/. "packages/parsers/src/gsapParser.test.ts", diff --git a/packages/core/src/generators/hyperframes.ts b/packages/core/src/generators/hyperframes.ts index 85374cbb5a..4b8db6376b 100644 --- a/packages/core/src/generators/hyperframes.ts +++ b/packages/core/src/generators/hyperframes.ts @@ -5,8 +5,8 @@ import { isMediaElement, isCompositionElement, } from "../core.types"; -import type { GsapAnimation } from "../parsers/gsapSerialize"; -import { serializeGsapAnimations, keyframesToGsapAnimations } from "../parsers/gsapSerialize"; +import type { GsapAnimation } from "@hyperframes/parsers"; +import { serializeGsapAnimations, keyframesToGsapAnimations } from "@hyperframes/parsers"; import { GSAP_CDN, BASE_STYLES, ZOOM_CONTAINER_STYLES } from "../templates/constants"; const GOOGLE_FONTS_BASE = "https://fonts.googleapis.com/css2"; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c29992ba3d..52da038494 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -80,7 +80,7 @@ export { // Parsers — GSAP helpers. The AST parser (parseGsapScriptAcorn and write ops) // is browser-safe; mutation helpers are in gsapWriterAcorn. -export type { GsapAnimation, GsapMethod, ParsedGsap } from "./parsers/gsapSerialize"; +export type { GsapAnimation, GsapMethod, ParsedGsap } from "@hyperframes/parsers"; export { serializeGsapAnimations, @@ -88,9 +88,9 @@ export { validateCompositionGsap, keyframesToGsapAnimations, gsapAnimationsToKeyframes, -} from "./parsers/gsapSerialize"; +} from "@hyperframes/parsers"; -export type { ParsedHtml, CompositionMetadata } from "./parsers/htmlParser"; +export type { ParsedHtml, CompositionMetadata } from "@hyperframes/parsers"; export { parseHtml, @@ -99,7 +99,7 @@ export { removeElementFromHtml, validateCompositionHtml, extractCompositionMetadata, -} from "./parsers/htmlParser"; +} from "@hyperframes/parsers"; // Generators export type { SerializeOptions } from "./generators/hyperframes"; diff --git a/packages/core/src/parsers/htmlParser.ts b/packages/core/src/parsers/htmlParser.ts deleted file mode 100644 index c405535b25..0000000000 --- a/packages/core/src/parsers/htmlParser.ts +++ /dev/null @@ -1,10 +0,0 @@ -// ponytail: compat re-export — moved to @hyperframes/parsers -export type { ParsedHtml, CompositionMetadata } from "@hyperframes/parsers"; -export { - parseHtml, - updateElementInHtml, - addElementToHtml, - removeElementFromHtml, - validateCompositionHtml, - extractCompositionMetadata, -} from "@hyperframes/parsers"; From dfe0e4b5678a24b6770bcfe81e28ea4a54ca8f8c Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 26 Jun 2026 18:20:27 -0400 Subject: [PATCH 05/11] refactor: create @hyperframes/lint package --- .fallowrc.jsonc | 17 ++++++ bun.lock | 19 +++++++ packages/core/package.json | 1 + packages/core/src/compiler/staticGuard.ts | 2 +- packages/core/src/index.ts | 4 +- packages/core/src/lint/index.ts | 9 +--- packages/core/src/slideshow/index.ts | 1 + packages/lint/package.json | 54 +++++++++++++++++++ .../{core/src/lint => lint/src}/context.ts | 0 .../src}/hyperframeLinter.test.ts | 0 .../src/lint => lint/src}/hyperframeLinter.ts | 0 packages/lint/src/index.ts | 7 +++ .../lint => lint/src}/rules/adapters.test.ts | 0 .../src/lint => lint/src}/rules/adapters.ts | 0 .../lint => lint/src}/rules/captions.test.ts | 0 .../src/lint => lint/src}/rules/captions.ts | 0 .../src}/rules/composition.test.ts | 0 .../lint => lint/src}/rules/composition.ts | 2 +- .../src/lint => lint/src}/rules/core.test.ts | 0 .../{core/src/lint => lint/src}/rules/core.ts | 0 .../src/lint => lint/src}/rules/fonts.test.ts | 0 .../src/lint => lint/src}/rules/fonts.ts | 2 +- .../src/lint => lint/src}/rules/gsap.test.ts | 0 .../{core/src/lint => lint/src}/rules/gsap.ts | 2 +- .../src/lint => lint/src}/rules/media.test.ts | 0 .../src/lint => lint/src}/rules/media.ts | 0 .../lint => lint/src}/rules/slideshow.test.ts | 0 .../src/lint => lint/src}/rules/slideshow.ts | 7 ++- .../lint => lint/src}/rules/textures.test.ts | 0 .../src/lint => lint/src}/rules/textures.ts | 0 packages/{core/src/lint => lint/src}/types.ts | 0 packages/{core/src/lint => lint/src}/utils.ts | 0 packages/lint/tsconfig.json | 18 +++++++ packages/lint/tsup.config.ts | 14 +++++ packages/lint/vitest.config.ts | 8 +++ 35 files changed, 152 insertions(+), 15 deletions(-) create mode 100644 packages/lint/package.json rename packages/{core/src/lint => lint/src}/context.ts (100%) rename packages/{core/src/lint => lint/src}/hyperframeLinter.test.ts (100%) rename packages/{core/src/lint => lint/src}/hyperframeLinter.ts (100%) create mode 100644 packages/lint/src/index.ts rename packages/{core/src/lint => lint/src}/rules/adapters.test.ts (100%) rename packages/{core/src/lint => lint/src}/rules/adapters.ts (100%) rename packages/{core/src/lint => lint/src}/rules/captions.test.ts (100%) rename packages/{core/src/lint => lint/src}/rules/captions.ts (100%) rename packages/{core/src/lint => lint/src}/rules/composition.test.ts (100%) rename packages/{core/src/lint => lint/src}/rules/composition.ts (99%) rename packages/{core/src/lint => lint/src}/rules/core.test.ts (100%) rename packages/{core/src/lint => lint/src}/rules/core.ts (100%) rename packages/{core/src/lint => lint/src}/rules/fonts.test.ts (100%) rename packages/{core/src/lint => lint/src}/rules/fonts.ts (98%) rename packages/{core/src/lint => lint/src}/rules/gsap.test.ts (100%) rename packages/{core/src/lint => lint/src}/rules/gsap.ts (99%) rename packages/{core/src/lint => lint/src}/rules/media.test.ts (100%) rename packages/{core/src/lint => lint/src}/rules/media.ts (100%) rename packages/{core/src/lint => lint/src}/rules/slideshow.test.ts (100%) rename packages/{core/src/lint => lint/src}/rules/slideshow.ts (94%) rename packages/{core/src/lint => lint/src}/rules/textures.test.ts (100%) rename packages/{core/src/lint => lint/src}/rules/textures.ts (100%) rename packages/{core/src/lint => lint/src}/types.ts (100%) rename packages/{core/src/lint => lint/src}/utils.ts (100%) create mode 100644 packages/lint/tsconfig.json create mode 100644 packages/lint/tsup.config.ts create mode 100644 packages/lint/vitest.config.ts diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 29a89c925c..7c4f01d3c2 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -267,6 +267,18 @@ "packages/parsers/src/gsapWriterParity.acorn.test.ts", "packages/parsers/src/htmlParser.roundtrip.test.ts", "packages/parsers/src/htmlParser.test.ts", + // @hyperframes/lint rule test files: parallel arrange/act/assert test cases + // (pre-existing structure from when lint lived in packages/core/src/lint/). + "packages/lint/src/rules/adapters.test.ts", + "packages/lint/src/rules/captions.test.ts", + "packages/lint/src/rules/composition.test.ts", + "packages/lint/src/rules/core.test.ts", + "packages/lint/src/rules/fonts.test.ts", + "packages/lint/src/rules/gsap.test.ts", + "packages/lint/src/rules/media.test.ts", + "packages/lint/src/rules/slideshow.test.ts", + "packages/lint/src/rules/textures.test.ts", + "packages/lint/src/hyperframeLinter.test.ts", // slideshowPanelHelpers.ts: setSlideNotes/addFragment/addHotspot share an // intentional parallel shape (signature + mapSlidesIn → exists-check → // map/append); the per-slide mutation differs, so a shared abstraction @@ -313,6 +325,11 @@ "packages/parsers/src/gsapParser.ts", // htmlParser.ts has pre-existing complexity (moved from packages/core). "packages/parsers/src/htmlParser.ts", + // media.ts and textures.ts: lint rule implementations with pre-existing + // complexity (moved from packages/core/src/lint/rules/). File-level + // exemption avoids the line-shift fingerprint problem for inherited findings. + "packages/lint/src/rules/media.ts", + "packages/lint/src/rules/textures.ts", // SlideshowPanel.tsx: top-level editor panel that wires several independent // sections (slides/inspector/branches/hotspot). Its cyclomatic count comes // from that fan-out; splitting it would scatter shared state without diff --git a/bun.lock b/bun.lock index fe57720b92..203b97d337 100644 --- a/bun.lock +++ b/bun.lock @@ -104,6 +104,7 @@ "version": "0.7.11", "dependencies": { "@chenglou/pretext": "^0.0.5", + "@hyperframes/lint": "workspace:*", "@hyperframes/parsers": "workspace:*", "bpm-detective": "^2.0.5", "postcss": "^8.5.8", @@ -166,6 +167,22 @@ "typescript": "^5.7.2", }, }, + "packages/lint": { + "name": "@hyperframes/lint", + "version": "0.7.11", + "dependencies": { + "@hyperframes/core": "workspace:*", + "@hyperframes/parsers": "workspace:*", + "postcss": "^8.5.8", + }, + "devDependencies": { + "@types/node": "^25.0.10", + "tsup": "^8.0.0", + "tsx": "^4.21.0", + "typescript": "^5.0.0", + "vitest": "^3.2.4", + }, + }, "packages/parsers": { "name": "@hyperframes/parsers", "version": "0.7.11", @@ -682,6 +699,8 @@ "@hyperframes/gcp-cloud-run": ["@hyperframes/gcp-cloud-run@workspace:packages/gcp-cloud-run"], + "@hyperframes/lint": ["@hyperframes/lint@workspace:packages/lint"], + "@hyperframes/parsers": ["@hyperframes/parsers@workspace:packages/parsers"], "@hyperframes/player": ["@hyperframes/player@workspace:packages/player"], diff --git a/packages/core/package.json b/packages/core/package.json index 80e5835a03..8420e9236b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -281,6 +281,7 @@ }, "dependencies": { "@chenglou/pretext": "^0.0.5", + "@hyperframes/lint": "workspace:*", "@hyperframes/parsers": "workspace:*", "bpm-detective": "^2.0.5", "postcss": "^8.5.8", diff --git a/packages/core/src/compiler/staticGuard.ts b/packages/core/src/compiler/staticGuard.ts index a5fd791a8f..568a6133a6 100644 --- a/packages/core/src/compiler/staticGuard.ts +++ b/packages/core/src/compiler/staticGuard.ts @@ -1,4 +1,4 @@ -import { lintHyperframeHtml } from "../lint/hyperframeLinter"; +import { lintHyperframeHtml } from "@hyperframes/lint"; export type HyperframeStaticFailureReason = | "missing_composition_id" diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 52da038494..4f4e244eed 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -144,8 +144,8 @@ export type { HyperframeLintFinding, HyperframeLintResult, HyperframeLinterOptions, -} from "./lint/types"; -export { lintHyperframeHtml } from "./lint/hyperframeLinter"; +} from "@hyperframes/lint"; +export { lintHyperframeHtml } from "@hyperframes/lint"; export { rewriteAssetPaths, rewriteAssetPath, diff --git a/packages/core/src/lint/index.ts b/packages/core/src/lint/index.ts index 4d0c0eb4ae..209e7cd5a9 100644 --- a/packages/core/src/lint/index.ts +++ b/packages/core/src/lint/index.ts @@ -1,7 +1,2 @@ -export type { - HyperframeLintSeverity, - HyperframeLintFinding, - HyperframeLintResult, - HyperframeLinterOptions, -} from "./types"; -export { lintHyperframeHtml, lintMediaUrls } from "./hyperframeLinter"; +/** @deprecated Import from @hyperframes/lint */ +export * from "@hyperframes/lint"; diff --git a/packages/core/src/slideshow/index.ts b/packages/core/src/slideshow/index.ts index 084a555bf3..4612820a55 100644 --- a/packages/core/src/slideshow/index.ts +++ b/packages/core/src/slideshow/index.ts @@ -1,2 +1,3 @@ export * from "./slideshow.types"; export * from "./parseSlideshow"; +export { isSceneLikeCompositionId } from "./sceneId"; diff --git a/packages/lint/package.json b/packages/lint/package.json new file mode 100644 index 0000000000..870683ec56 --- /dev/null +++ b/packages/lint/package.json @@ -0,0 +1,54 @@ +{ + "name": "@hyperframes/lint", + "version": "0.7.11", + "repository": { + "type": "git", + "url": "https://github.com/heygen-com/hyperframes", + "directory": "packages/lint" + }, + "files": [ + "dist", + "README.md" + ], + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "import": "./src/index.ts", + "types": "./src/index.ts" + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "./package.json": "./package.json" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "scripts": { + "build": "tsup", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit", + "prepublishOnly": "echo skip" + }, + "dependencies": { + "@hyperframes/core": "workspace:*", + "@hyperframes/parsers": "workspace:*", + "postcss": "^8.5.8" + }, + "devDependencies": { + "@types/node": "^25.0.10", + "tsup": "^8.0.0", + "tsx": "^4.21.0", + "typescript": "^5.0.0", + "vitest": "^3.2.4" + } +} diff --git a/packages/core/src/lint/context.ts b/packages/lint/src/context.ts similarity index 100% rename from packages/core/src/lint/context.ts rename to packages/lint/src/context.ts diff --git a/packages/core/src/lint/hyperframeLinter.test.ts b/packages/lint/src/hyperframeLinter.test.ts similarity index 100% rename from packages/core/src/lint/hyperframeLinter.test.ts rename to packages/lint/src/hyperframeLinter.test.ts diff --git a/packages/core/src/lint/hyperframeLinter.ts b/packages/lint/src/hyperframeLinter.ts similarity index 100% rename from packages/core/src/lint/hyperframeLinter.ts rename to packages/lint/src/hyperframeLinter.ts diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts new file mode 100644 index 0000000000..4d0c0eb4ae --- /dev/null +++ b/packages/lint/src/index.ts @@ -0,0 +1,7 @@ +export type { + HyperframeLintSeverity, + HyperframeLintFinding, + HyperframeLintResult, + HyperframeLinterOptions, +} from "./types"; +export { lintHyperframeHtml, lintMediaUrls } from "./hyperframeLinter"; diff --git a/packages/core/src/lint/rules/adapters.test.ts b/packages/lint/src/rules/adapters.test.ts similarity index 100% rename from packages/core/src/lint/rules/adapters.test.ts rename to packages/lint/src/rules/adapters.test.ts diff --git a/packages/core/src/lint/rules/adapters.ts b/packages/lint/src/rules/adapters.ts similarity index 100% rename from packages/core/src/lint/rules/adapters.ts rename to packages/lint/src/rules/adapters.ts diff --git a/packages/core/src/lint/rules/captions.test.ts b/packages/lint/src/rules/captions.test.ts similarity index 100% rename from packages/core/src/lint/rules/captions.test.ts rename to packages/lint/src/rules/captions.test.ts diff --git a/packages/core/src/lint/rules/captions.ts b/packages/lint/src/rules/captions.ts similarity index 100% rename from packages/core/src/lint/rules/captions.ts rename to packages/lint/src/rules/captions.ts diff --git a/packages/core/src/lint/rules/composition.test.ts b/packages/lint/src/rules/composition.test.ts similarity index 100% rename from packages/core/src/lint/rules/composition.test.ts rename to packages/lint/src/rules/composition.test.ts diff --git a/packages/core/src/lint/rules/composition.ts b/packages/lint/src/rules/composition.ts similarity index 99% rename from packages/core/src/lint/rules/composition.ts rename to packages/lint/src/rules/composition.ts index 19e20fd0c3..4c1ccd1499 100644 --- a/packages/core/src/lint/rules/composition.ts +++ b/packages/lint/src/rules/composition.ts @@ -1,6 +1,6 @@ import type { LintContext, HyperframeLintFinding, ExtractedBlock } from "../context"; import { findHtmlTag, readAttr, readJsonAttr, stripJsComments, truncateSnippet } from "../utils"; -import { COMPOSITION_VARIABLE_TYPES } from "../../core.types"; +import { COMPOSITION_VARIABLE_TYPES } from "@hyperframes/parsers"; // Agent guidance thresholds: warning-only nudges for files/tracks that become hard // to inspect and revise reliably in a single composition. diff --git a/packages/core/src/lint/rules/core.test.ts b/packages/lint/src/rules/core.test.ts similarity index 100% rename from packages/core/src/lint/rules/core.test.ts rename to packages/lint/src/rules/core.test.ts diff --git a/packages/core/src/lint/rules/core.ts b/packages/lint/src/rules/core.ts similarity index 100% rename from packages/core/src/lint/rules/core.ts rename to packages/lint/src/rules/core.ts diff --git a/packages/core/src/lint/rules/fonts.test.ts b/packages/lint/src/rules/fonts.test.ts similarity index 100% rename from packages/core/src/lint/rules/fonts.test.ts rename to packages/lint/src/rules/fonts.test.ts diff --git a/packages/core/src/lint/rules/fonts.ts b/packages/lint/src/rules/fonts.ts similarity index 98% rename from packages/core/src/lint/rules/fonts.ts rename to packages/lint/src/rules/fonts.ts index 37ab682a2a..63fea4e49e 100644 --- a/packages/core/src/lint/rules/fonts.ts +++ b/packages/lint/src/rules/fonts.ts @@ -1,4 +1,4 @@ -import { FONT_ALIAS_KEYS, resolveAliasDisplayName } from "../../fonts/aliases"; +import { FONT_ALIAS_KEYS, resolveAliasDisplayName } from "@hyperframes/core/fonts/aliases"; import type { LintContext, HyperframeLintFinding } from "../context"; import { isRegistrySourceFile, isRegistryInstalledFile } from "./composition"; diff --git a/packages/core/src/lint/rules/gsap.test.ts b/packages/lint/src/rules/gsap.test.ts similarity index 100% rename from packages/core/src/lint/rules/gsap.test.ts rename to packages/lint/src/rules/gsap.test.ts diff --git a/packages/core/src/lint/rules/gsap.ts b/packages/lint/src/rules/gsap.ts similarity index 99% rename from packages/core/src/lint/rules/gsap.ts rename to packages/lint/src/rules/gsap.ts index 570146d1c6..ef792db500 100644 --- a/packages/core/src/lint/rules/gsap.ts +++ b/packages/lint/src/rules/gsap.ts @@ -16,7 +16,7 @@ interface LintParsedGsap { // instead of all-collapsed-at-0. It's also browser-safe, so this keeps recast // out of the lint graph entirely. Dynamic import preserves the lazy load. async function loadParseGsapScript(): Promise<(script: string) => LintParsedGsap> { - const mod = await import("../../parsers/gsapParserAcorn.js"); + const mod = await import("@hyperframes/parsers/gsap-parser-acorn"); return mod.parseGsapScriptAcorn as unknown as (script: string) => LintParsedGsap; } import type { LintContext } from "../context"; diff --git a/packages/core/src/lint/rules/media.test.ts b/packages/lint/src/rules/media.test.ts similarity index 100% rename from packages/core/src/lint/rules/media.test.ts rename to packages/lint/src/rules/media.test.ts diff --git a/packages/core/src/lint/rules/media.ts b/packages/lint/src/rules/media.ts similarity index 100% rename from packages/core/src/lint/rules/media.ts rename to packages/lint/src/rules/media.ts diff --git a/packages/core/src/lint/rules/slideshow.test.ts b/packages/lint/src/rules/slideshow.test.ts similarity index 100% rename from packages/core/src/lint/rules/slideshow.test.ts rename to packages/lint/src/rules/slideshow.test.ts diff --git a/packages/core/src/lint/rules/slideshow.ts b/packages/lint/src/rules/slideshow.ts similarity index 94% rename from packages/core/src/lint/rules/slideshow.ts rename to packages/lint/src/rules/slideshow.ts index 80b5d8f449..5580dc66ad 100644 --- a/packages/core/src/lint/rules/slideshow.ts +++ b/packages/lint/src/rules/slideshow.ts @@ -1,8 +1,11 @@ import type { LintContext, HyperframeLintFinding } from "../context"; import type { LintRule } from "../types"; import { readAttr } from "../utils"; -import { parseSlideshowManifest, resolveSlideshow } from "../../slideshow/parseSlideshow"; -import { isSceneLikeCompositionId } from "../../slideshow/sceneId"; +import { + parseSlideshowManifest, + resolveSlideshow, + isSceneLikeCompositionId, +} from "@hyperframes/core/slideshow"; type Scene = { id: string; start: number; duration: number }; diff --git a/packages/core/src/lint/rules/textures.test.ts b/packages/lint/src/rules/textures.test.ts similarity index 100% rename from packages/core/src/lint/rules/textures.test.ts rename to packages/lint/src/rules/textures.test.ts diff --git a/packages/core/src/lint/rules/textures.ts b/packages/lint/src/rules/textures.ts similarity index 100% rename from packages/core/src/lint/rules/textures.ts rename to packages/lint/src/rules/textures.ts diff --git a/packages/core/src/lint/types.ts b/packages/lint/src/types.ts similarity index 100% rename from packages/core/src/lint/types.ts rename to packages/lint/src/types.ts diff --git a/packages/core/src/lint/utils.ts b/packages/lint/src/utils.ts similarity index 100% rename from packages/core/src/lint/utils.ts rename to packages/lint/src/utils.ts diff --git a/packages/lint/tsconfig.json b/packages/lint/tsconfig.json new file mode 100644 index 0000000000..96f38bce00 --- /dev/null +++ b/packages/lint/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noUncheckedIndexedAccess": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} diff --git a/packages/lint/tsup.config.ts b/packages/lint/tsup.config.ts new file mode 100644 index 0000000000..f5380e58e2 --- /dev/null +++ b/packages/lint/tsup.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: { index: "src/index.ts" }, + format: ["esm"], + outDir: "dist", + target: "node22", + platform: "node", + bundle: true, + splitting: false, + sourcemap: true, + clean: true, + dts: true, +}); diff --git a/packages/lint/vitest.config.ts b/packages/lint/vitest.config.ts new file mode 100644 index 0000000000..dc1fee04c8 --- /dev/null +++ b/packages/lint/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + environment: "jsdom", + }, +}); From d3f971ddb5ea80cdf99f5df25a843acb8a69b531 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 26 Jun 2026 18:29:23 -0400 Subject: [PATCH 06/11] refactor: create @hyperframes/studio-server package --- .fallowrc.jsonc | 38 +- bun.lock | 29 +- packages/core/package.json | 12 +- packages/core/src/index.ts | 1 + packages/core/src/parsers/gsapUnroll.ts | 2 - .../src/studio-api/helpers/draftMarkers.ts | 12 +- .../src/studio-api/helpers/finiteMutation.ts | 40 +- .../helpers/manualEditsRenderScript.ts | 737 +----------------- .../src/studio-api/helpers/screenshotClip.ts | 33 +- .../helpers/studioMotionRenderScript.ts | 262 +------ packages/core/src/studio-api/index.ts | 20 +- packages/studio-server/package.json | 97 +++ .../src}/createStudioApi.ts | 0 .../src}/helpers/backupJournal.test.ts | 0 .../src}/helpers/backupJournal.ts | 0 .../studio-server/src/helpers/draftMarkers.ts | 10 + .../src}/helpers/finiteMutation.test.ts | 0 .../src/helpers/finiteMutation.ts | 38 + .../src}/helpers/hfIdPersist.test.ts | 0 .../src}/helpers/hfIdPersist.ts | 2 +- .../helpers/manualEditsRenderScript.test.ts | 0 .../src/helpers/manualEditsRenderScript.ts | 735 +++++++++++++++++ .../src}/helpers/mediaValidation.test.ts | 0 .../src}/helpers/mediaValidation.ts | 0 .../src}/helpers/mime.ts | 0 .../src}/helpers/previewAdapter.test.ts | 0 .../src}/helpers/previewAdapter.ts | 0 .../src}/helpers/projectSignature.ts | 0 .../src}/helpers/safePath.test.ts | 0 .../src}/helpers/safePath.ts | 2 +- .../src/helpers/screenshotClip.ts | 31 + .../src}/helpers/sourceMutation.test.ts | 0 .../src}/helpers/sourceMutation.ts | 2 +- .../helpers/studioMotionRenderScript.test.ts | 0 .../src/helpers/studioMotionRenderScript.ts | 260 ++++++ .../src}/helpers/subComposition.test.ts | 0 .../src}/helpers/subComposition.ts | 4 +- .../src}/helpers/waveform.ts | 0 packages/studio-server/src/index.ts | 18 + .../src}/routes/files.test.ts | 0 .../src}/routes/files.ts | 14 +- .../src}/routes/fonts.ts | 2 +- .../src}/routes/lint.test.ts | 0 .../src}/routes/lint.ts | 0 .../src}/routes/preview.test.ts | 0 .../src}/routes/preview.ts | 4 +- .../src}/routes/projects.test.ts | 0 .../src}/routes/projects.ts | 0 .../src}/routes/registry.ts | 0 .../src}/routes/render.test.ts | 2 +- .../src}/routes/render.ts | 3 +- .../src}/routes/storyboard.test.ts | 0 .../src}/routes/storyboard.ts | 2 +- .../src}/routes/thumbnail.test.ts | 0 .../src}/routes/thumbnail.ts | 0 .../src}/routes/waveform.ts | 0 .../studio-api => studio-server/src}/types.ts | 6 +- packages/studio-server/tsconfig.json | 18 + packages/studio-server/tsup.config.ts | 21 + packages/studio-server/vitest.config.ts | 8 + 60 files changed, 1325 insertions(+), 1140 deletions(-) delete mode 100644 packages/core/src/parsers/gsapUnroll.ts create mode 100644 packages/studio-server/package.json rename packages/{core/src/studio-api => studio-server/src}/createStudioApi.ts (100%) rename packages/{core/src/studio-api => studio-server/src}/helpers/backupJournal.test.ts (100%) rename packages/{core/src/studio-api => studio-server/src}/helpers/backupJournal.ts (100%) create mode 100644 packages/studio-server/src/helpers/draftMarkers.ts rename packages/{core/src/studio-api => studio-server/src}/helpers/finiteMutation.test.ts (100%) create mode 100644 packages/studio-server/src/helpers/finiteMutation.ts rename packages/{core/src/studio-api => studio-server/src}/helpers/hfIdPersist.test.ts (100%) rename packages/{core/src/studio-api => studio-server/src}/helpers/hfIdPersist.ts (96%) rename packages/{core/src/studio-api => studio-server/src}/helpers/manualEditsRenderScript.test.ts (100%) create mode 100644 packages/studio-server/src/helpers/manualEditsRenderScript.ts rename packages/{core/src/studio-api => studio-server/src}/helpers/mediaValidation.test.ts (100%) rename packages/{core/src/studio-api => studio-server/src}/helpers/mediaValidation.ts (100%) rename packages/{core/src/studio-api => studio-server/src}/helpers/mime.ts (100%) rename packages/{core/src/studio-api => studio-server/src}/helpers/previewAdapter.test.ts (100%) rename packages/{core/src/studio-api => studio-server/src}/helpers/previewAdapter.ts (100%) rename packages/{core/src/studio-api => studio-server/src}/helpers/projectSignature.ts (100%) rename packages/{core/src/studio-api => studio-server/src}/helpers/safePath.test.ts (100%) rename packages/{core/src/studio-api => studio-server/src}/helpers/safePath.ts (95%) create mode 100644 packages/studio-server/src/helpers/screenshotClip.ts rename packages/{core/src/studio-api => studio-server/src}/helpers/sourceMutation.test.ts (100%) rename packages/{core/src/studio-api => studio-server/src}/helpers/sourceMutation.ts (99%) rename packages/{core/src/studio-api => studio-server/src}/helpers/studioMotionRenderScript.test.ts (100%) create mode 100644 packages/studio-server/src/helpers/studioMotionRenderScript.ts rename packages/{core/src/studio-api => studio-server/src}/helpers/subComposition.test.ts (100%) rename packages/{core/src/studio-api => studio-server/src}/helpers/subComposition.ts (99%) rename packages/{core/src/studio-api => studio-server/src}/helpers/waveform.ts (100%) create mode 100644 packages/studio-server/src/index.ts rename packages/{core/src/studio-api => studio-server/src}/routes/files.test.ts (100%) rename packages/{core/src/studio-api => studio-server/src}/routes/files.ts (99%) rename packages/{core/src/studio-api => studio-server/src}/routes/fonts.ts (99%) rename packages/{core/src/studio-api => studio-server/src}/routes/lint.test.ts (100%) rename packages/{core/src/studio-api => studio-server/src}/routes/lint.ts (100%) rename packages/{core/src/studio-api => studio-server/src}/routes/preview.test.ts (100%) rename packages/{core/src/studio-api => studio-server/src}/routes/preview.ts (99%) rename packages/{core/src/studio-api => studio-server/src}/routes/projects.test.ts (100%) rename packages/{core/src/studio-api => studio-server/src}/routes/projects.ts (100%) rename packages/{core/src/studio-api => studio-server/src}/routes/registry.ts (100%) rename packages/{core/src/studio-api => studio-server/src}/routes/render.test.ts (99%) rename packages/{core/src/studio-api => studio-server/src}/routes/render.ts (98%) rename packages/{core/src/studio-api => studio-server/src}/routes/storyboard.test.ts (100%) rename packages/{core/src/studio-api => studio-server/src}/routes/storyboard.ts (98%) rename packages/{core/src/studio-api => studio-server/src}/routes/thumbnail.test.ts (100%) rename packages/{core/src/studio-api => studio-server/src}/routes/thumbnail.ts (100%) rename packages/{core/src/studio-api => studio-server/src}/routes/waveform.ts (100%) rename packages/{core/src/studio-api => studio-server/src}/types.ts (96%) create mode 100644 packages/studio-server/tsconfig.json create mode 100644 packages/studio-server/tsup.config.ts create mode 100644 packages/studio-server/vitest.config.ts diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 7c4f01d3c2..925915c5ee 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -267,6 +267,25 @@ "packages/parsers/src/gsapWriterParity.acorn.test.ts", "packages/parsers/src/htmlParser.roundtrip.test.ts", "packages/parsers/src/htmlParser.test.ts", + // @hyperframes/studio-server test files: parallel arrange/act/assert test cases + // (pre-existing structure from when studio-api lived in packages/core/src/studio-api/). + "packages/studio-server/src/routes/files.test.ts", + "packages/studio-server/src/routes/render.test.ts", + "packages/studio-server/src/routes/lint.test.ts", + "packages/studio-server/src/routes/preview.test.ts", + "packages/studio-server/src/routes/projects.test.ts", + "packages/studio-server/src/routes/storyboard.test.ts", + "packages/studio-server/src/routes/thumbnail.test.ts", + "packages/studio-server/src/helpers/backupJournal.test.ts", + "packages/studio-server/src/helpers/finiteMutation.test.ts", + "packages/studio-server/src/helpers/hfIdPersist.test.ts", + "packages/studio-server/src/helpers/manualEditsRenderScript.test.ts", + "packages/studio-server/src/helpers/mediaValidation.test.ts", + "packages/studio-server/src/helpers/previewAdapter.test.ts", + "packages/studio-server/src/helpers/safePath.test.ts", + "packages/studio-server/src/helpers/sourceMutation.test.ts", + "packages/studio-server/src/helpers/studioMotionRenderScript.test.ts", + "packages/studio-server/src/helpers/subComposition.test.ts", // @hyperframes/lint rule test files: parallel arrange/act/assert test cases // (pre-existing structure from when lint lived in packages/core/src/lint/). "packages/lint/src/rules/adapters.test.ts", @@ -301,17 +320,10 @@ ], }, "health": { - // executeGsapMutation (introduced by Phase 3b / acorn-parser stack, already - // merged to origin/main via #1338) has CRITICAL cyclomatic complexity (58) - // that pre-dates this PR's scope. Excluding files.ts from health analysis - // avoids the inherited-fingerprint line-shift problem that suppression - // comments would cause (any inserted line shifts subsequent function line - // numbers, breaking fallow's inherited-detection fingerprint). - // // useGsapTweenCache.ts: pre-existing large React-effect hooks (the populate // and runtime-scan effects, the per-element animations memo) whose // complexity pre-dates the computed-timeline work. Exempted at file level - // for the same reason as files.ts rather than refactored as scope creep. + // for the same reason as gsapParser.ts rather than refactored as scope creep. // // gsapParser.ts: the recast/babel GSAP writer is a 2500-line legacy parser // restored as the default server writer by WS-3.F rework (acorn is now @@ -319,7 +331,6 @@ // this PR and was present on all ancestor branches; the file-level exemption // avoids the line-shift fingerprint problem for inherited findings. "ignore": [ - "packages/core/src/studio-api/routes/files.ts", "packages/core/src/parsers/gsapParser.ts", // gsapParser.ts moved to packages/parsers — same complexity rationale. "packages/parsers/src/gsapParser.ts", @@ -330,6 +341,15 @@ // exemption avoids the line-shift fingerprint problem for inherited findings. "packages/lint/src/rules/media.ts", "packages/lint/src/rules/textures.ts", + // studio-server files: pre-existing complexity (moved from packages/core/src/studio-api/). + // files.ts: executeGsapMutationRecast/Acorn are CRITICAL; excluded as files.ts + // was already in health.ignore at the old path (packages/core/src/studio-api/routes/files.ts). + "packages/studio-server/src/routes/files.ts", + "packages/studio-server/src/routes/thumbnail.ts", + "packages/studio-server/src/routes/render.ts", + "packages/studio-server/src/helpers/manualEditsRenderScript.ts", + "packages/studio-server/src/helpers/studioMotionRenderScript.ts", + "packages/studio-server/src/helpers/subComposition.ts", // SlideshowPanel.tsx: top-level editor panel that wires several independent // sections (slides/inspector/branches/hotspot). Its cyclomatic count comes // from that fan-out; splitting it would scatter shared state without diff --git a/bun.lock b/bun.lock index 203b97d337..33712fd910 100644 --- a/bun.lock +++ b/bun.lock @@ -106,6 +106,7 @@ "@chenglou/pretext": "^0.0.5", "@hyperframes/lint": "workspace:*", "@hyperframes/parsers": "workspace:*", + "@hyperframes/studio-server": "workspace:*", "bpm-detective": "^2.0.5", "postcss": "^8.5.8", "postcss-selector-parser": "^7.1.2", @@ -122,12 +123,6 @@ "optionalDependencies": { "esbuild": "^0.25.12", }, - "peerDependencies": { - "hono": "^4.0.0", - }, - "optionalPeers": [ - "hono", - ], }, "packages/engine": { "name": "@hyperframes/engine", @@ -341,6 +336,26 @@ "zustand": "^4.0.0 || ^5.0.0", }, }, + "packages/studio-server": { + "name": "@hyperframes/studio-server", + "version": "0.7.11", + "dependencies": { + "@hyperframes/core": "workspace:*", + "@hyperframes/lint": "workspace:*", + "@hyperframes/parsers": "workspace:*", + "hono": "^4.0.0", + "linkedom": "^0.18.12", + "postcss": "^8.5.8", + "postcss-selector-parser": "^7.1.2", + }, + "devDependencies": { + "@types/node": "^25.0.10", + "tsup": "^8.0.0", + "tsx": "^4.21.0", + "typescript": "^5.0.0", + "vitest": "^3.2.4", + }, + }, }, "overrides": { "@types/react": "^19.0.0", @@ -715,6 +730,8 @@ "@hyperframes/studio": ["@hyperframes/studio@workspace:packages/studio"], + "@hyperframes/studio-server": ["@hyperframes/studio-server@workspace:packages/studio-server"], + "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], diff --git a/packages/core/package.json b/packages/core/package.json index 8420e9236b..cce2f5c6eb 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -283,9 +283,9 @@ "@chenglou/pretext": "^0.0.5", "@hyperframes/lint": "workspace:*", "@hyperframes/parsers": "workspace:*", + "@hyperframes/studio-server": "workspace:*", "bpm-detective": "^2.0.5", - "postcss": "^8.5.8", - "postcss-selector-parser": "^7.1.2" + "postcss": "^8.5.8" }, "devDependencies": { "@types/jsdom": "^28.0.0", @@ -296,14 +296,6 @@ "typescript": "^5.0.0", "vitest": "^3.2.4" }, - "peerDependencies": { - "hono": "^4.0.0" - }, - "peerDependenciesMeta": { - "hono": { - "optional": true - } - }, "optionalDependencies": { "esbuild": "^0.25.12" } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4f4e244eed..a0fa9eca48 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -150,6 +150,7 @@ export { rewriteAssetPaths, rewriteAssetPath, rewriteCssAssetUrls, + rewriteInlineStyleAssetUrls, } from "./compiler/rewriteSubCompPaths"; export { CSS_URL_RE, isNonRelativeUrl, isPathInside } from "./compiler/assetPaths"; export { queryByAttr } from "./utils/cssSelector"; diff --git a/packages/core/src/parsers/gsapUnroll.ts b/packages/core/src/parsers/gsapUnroll.ts deleted file mode 100644 index 5e6d758598..0000000000 --- a/packages/core/src/parsers/gsapUnroll.ts +++ /dev/null @@ -1,2 +0,0 @@ -// ponytail: compat re-export — moved to @hyperframes/parsers -export { unrollComputedTimeline } from "@hyperframes/parsers"; diff --git a/packages/core/src/studio-api/helpers/draftMarkers.ts b/packages/core/src/studio-api/helpers/draftMarkers.ts index 38bc61861f..4484439394 100644 --- a/packages/core/src/studio-api/helpers/draftMarkers.ts +++ b/packages/core/src/studio-api/helpers/draftMarkers.ts @@ -1,10 +1,2 @@ -/** - * Draft-marker constants shared between core's PreviewAdapter and Studio's - * manual-edits code. CSS custom properties written during a drag gesture, plus - * the gesture marker attribute. Exported from @hyperframes/core/studio-api/draft-markers. - */ -export const STUDIO_OFFSET_X_PROP = "--hf-studio-offset-x"; -export const STUDIO_OFFSET_Y_PROP = "--hf-studio-offset-y"; -export const STUDIO_WIDTH_PROP = "--hf-studio-width"; -export const STUDIO_HEIGHT_PROP = "--hf-studio-height"; -export const STUDIO_MANUAL_EDIT_GESTURE_ATTR = "data-hf-studio-manual-edit-gesture"; +/** @deprecated Import from @hyperframes/studio-server/draft-markers */ +export * from "@hyperframes/studio-server/draft-markers"; diff --git a/packages/core/src/studio-api/helpers/finiteMutation.ts b/packages/core/src/studio-api/helpers/finiteMutation.ts index 1e145e4fae..82611bf430 100644 --- a/packages/core/src/studio-api/helpers/finiteMutation.ts +++ b/packages/core/src/studio-api/helpers/finiteMutation.ts @@ -1,38 +1,2 @@ -export interface UnsafeMutationValue { - path: string; - reason: "non-finite-number" | "null"; -} - -interface FindUnsafeMutationValuesOptions { - allowNullPath?: (path: string) => boolean; -} - -export function findUnsafeMutationValues( - value: unknown, - path = "body", - options: FindUnsafeMutationValuesOptions = {}, -): UnsafeMutationValue[] { - if (value === null) { - return options.allowNullPath?.(path) ? [] : [{ path, reason: "null" }]; - } - if (typeof value === "number") { - return Number.isFinite(value) ? [] : [{ path, reason: "non-finite-number" }]; - } - if (!value || typeof value !== "object") return []; - if (Array.isArray(value)) { - return value.flatMap((item, index) => - findUnsafeMutationValues(item, `${path}[${index}]`, options), - ); - } - return Object.entries(value).flatMap(([key, item]) => - findUnsafeMutationValues(item, `${path}.${key}`, options), - ); -} - -const DOM_PATCH_NULL_VALUE_PATH = /^body\.operations\[\d+\]\.value$/; - -export function findUnsafeDomPatchValues(value: unknown): UnsafeMutationValue[] { - return findUnsafeMutationValues(value, "body", { - allowNullPath: (path) => DOM_PATCH_NULL_VALUE_PATH.test(path), - }); -} +/** @deprecated Import from @hyperframes/studio-server/finite-mutation */ +export * from "@hyperframes/studio-server/finite-mutation"; diff --git a/packages/core/src/studio-api/helpers/manualEditsRenderScript.ts b/packages/core/src/studio-api/helpers/manualEditsRenderScript.ts index 2d6dd39aa5..d086ba8bb1 100644 --- a/packages/core/src/studio-api/helpers/manualEditsRenderScript.ts +++ b/packages/core/src/studio-api/helpers/manualEditsRenderScript.ts @@ -1,735 +1,2 @@ -// fallow-ignore-file code-duplication -export interface StudioManualEditsRenderScriptOptions { - activeCompositionPath?: string | null; -} - -export const STUDIO_MANUAL_EDITS_PATH = ".hyperframes/studio-manual-edits.json"; - -export function createStudioManualEditsRenderBodyScript( - manifestContent: string, - options: StudioManualEditsRenderScriptOptions = {}, -): string | null { - if (!manifestContent.trim()) return null; - return `(${studioManualEditsRenderRuntime.toString()})(${JSON.stringify(manifestContent)}, ${JSON.stringify(options.activeCompositionPath ?? null)});`; -} - -/** - * Returns a self-contained IIFE string that re-applies studio position edits - * (translate, rotate) after every GSAP seek by querying data attributes baked - * into the HTML. Works without a JSON manifest — positions are already inlined - * as CSS custom properties on the elements. - */ -export function createStudioPositionSeekReapplyScript(): string { - return `(${studioPositionSeekReapplyRuntime.toString()})();`; -} - -function studioPositionSeekReapplyRuntime(): void { - const OFFSET_X_PROP = "--hf-studio-offset-x"; - const OFFSET_Y_PROP = "--hf-studio-offset-y"; - const WIDTH_PROP = "--hf-studio-width"; - const HEIGHT_PROP = "--hf-studio-height"; - const ROTATION_PROP = "--hf-studio-rotation"; - const PATH_OFFSET_ATTR = "data-hf-studio-path-offset"; - const BOX_SIZE_ATTR = "data-hf-studio-box-size"; - const ROTATION_ATTR = "data-hf-studio-rotation"; - const ORIGINAL_TRANSLATE_ATTR = "data-hf-studio-original-translate"; - const ORIGINAL_ROTATE_ATTR = "data-hf-studio-original-rotate"; - const MOTION_ATTR = "data-hf-studio-motion"; - const MOTION_TL_KEY = "studio-motion"; - const WRAPPED_PROP = "__hfStudioPositionSeekReapplyWrapped"; - - if ( - !document.querySelector("[" + PATH_OFFSET_ATTR + '="true"]') && - !document.querySelector("[" + BOX_SIZE_ATTR + '="true"]') && - !document.querySelector("[" + ROTATION_ATTR + '="true"]') && - !document.querySelector("[" + MOTION_ATTR + "]") - ) - return; - - const splitTopLevelWhitespace = (value: string): string[] => { - const parts: string[] = []; - let depth = 0; - let current = ""; - for (const char of value.trim()) { - if (char === "(") depth += 1; - if (char === ")") depth = Math.max(0, depth - 1); - if (/\s/.test(char) && depth === 0) { - if (current) parts.push(current); - current = ""; - } else { - current += char; - } - } - if (current) parts.push(current); - return parts; - }; - - const composeTranslate = (element: HTMLElement, x: string, y: string): string => { - const original = element.getAttribute(ORIGINAL_TRANSLATE_ATTR)?.trim(); - if (!original || original === "none") return x + " " + y; - const parts = splitTopLevelWhitespace(original); - if (parts.length === 1) return "calc(" + parts[0] + " + " + x + ") " + y; - if (parts.length >= 2) { - const z = parts.length >= 3 ? " " + parts[2] : ""; - return "calc(" + parts[0] + " + " + x + ") calc(" + parts[1] + " + " + y + ")" + z; - } - return x + " " + y; - }; - - const isSimpleRotateAngle = (value: string): boolean => - /^-?(?:\d+(?:\.\d+)?|\.\d+)(?:deg|rad|turn|grad)$/.test(value.trim()); - - const composeRotation = (element: HTMLElement, rotationValue: string): string => { - const original = element.getAttribute(ORIGINAL_ROTATE_ATTR)?.trim(); - if (!original || original === "none" || !isSimpleRotateAngle(original)) return rotationValue; - return "calc(" + original + " + " + rotationValue + ")"; - }; - - let lastSeekTime = 0; - let cachedMotionKey = ""; - - const finiteNum = (v: unknown): number | null => - typeof v === "number" && Number.isFinite(v) ? v : null; - - const computeMotionKey = (motionEls: NodeListOf): string => { - let key = ""; - for (let i = 0; i < motionEls.length; i++) { - const json = (motionEls[i] as HTMLElement).getAttribute?.(MOTION_ATTR); - if (json) key += (key ? "\n" : "") + json; - } - return key; - }; - - const reapplyMotionTimeline = (): void => { - const motionEls = document.querySelectorAll("[" + MOTION_ATTR + "]"); - if (motionEls.length === 0) { - cachedMotionKey = ""; - return; - } - const win = window as Window & { - gsap?: { - timeline?: (opts: Record) => Record; - set?: (el: HTMLElement, vars: Record) => void; - registerPlugin?: (plugin: unknown) => void; - }; - CustomEase?: { create?: (id: string, data: string) => void }; - __timelines?: Record>; - }; - const gsap = win.gsap; - if (!gsap || typeof gsap.timeline !== "function") return; - win.__timelines = win.__timelines || {}; - - // Cache the timeline keyed by the concatenated motion JSON strings. - // On each seek, if the key hasn't changed, just seek the existing timeline - // instead of rebuilding it (avoids kill+recreate on every frame). - const motionKey = computeMotionKey(motionEls); - const existing = win.__timelines[MOTION_TL_KEY]; - if ( - motionKey && - motionKey === cachedMotionKey && - existing && - typeof existing.totalTime === "function" - ) { - (existing.totalTime as (t: number, s: boolean) => void)(lastSeekTime, false); - return; - } - - if (existing && typeof existing.kill === "function") (existing.kill as () => void)(); - const tl = gsap.timeline({ paused: true, defaults: { overwrite: "auto" } }); - const fromTo = tl.fromTo as ( - el: HTMLElement, - from: Record, - to: Record, - pos: number, - ) => void; - if (typeof fromTo !== "function") return; - let applied = 0; - for (let i = 0; i < motionEls.length; i++) { - const el = motionEls[i] as HTMLElement; - if (!(el instanceof HTMLElement)) continue; - const json = el.getAttribute(MOTION_ATTR); - if (!json) continue; - try { - const m = JSON.parse(json) as Record; - const start = finiteNum(m.start); - const duration = finiteNum(m.duration); - if (start == null || duration == null || duration <= 0) continue; - const ease = typeof m.ease === "string" ? m.ease : "none"; - const from = (m.from && typeof m.from === "object" ? m.from : {}) as Record< - string, - unknown - >; - const to = (m.to && typeof m.to === "object" ? m.to : {}) as Record; - const customEase = m.customEase as { id?: string; data?: string } | null | undefined; - let resolvedEase = ease; - if (customEase?.id && customEase?.data && win.CustomEase?.create) { - try { - gsap.registerPlugin?.(win.CustomEase); - win.CustomEase.create(customEase.id, customEase.data); - resolvedEase = customEase.id; - } catch { - /* use default ease */ - } - } - fromTo.call( - tl, - el, - { ...from }, - { ...to, duration, ease: resolvedEase, overwrite: "auto", immediateRender: false }, - start, - ); - applied += 1; - } catch { - /* malformed JSON — skip */ - } - } - if (applied === 0) { - cachedMotionKey = ""; - if (typeof (tl as { kill?: () => void }).kill === "function") - (tl as { kill: () => void }).kill(); - return; - } - cachedMotionKey = motionKey; - win.__timelines[MOTION_TL_KEY] = tl; - if (typeof tl.pause === "function") (tl.pause as () => void)(); - if (typeof tl.totalTime === "function") - (tl.totalTime as (t: number, s: boolean) => void)(lastSeekTime, false); - }; - - const stripGsapTranslateFromTransform = (el: HTMLElement): void => { - const transform = el.style.getPropertyValue("transform"); - if (!transform || transform === "none") return; - const win = el.ownerDocument.defaultView as (Window & typeof globalThis) | null; - const MatrixCtor = (win as unknown as { DOMMatrix?: typeof DOMMatrix })?.DOMMatrix; - if (!MatrixCtor) return; - try { - const m = new MatrixCtor(transform); - if (m.m41 === 0 && m.m42 === 0) return; - m.m41 = 0; - m.m42 = 0; - if (m.is2D && m.a === 1 && m.b === 0 && m.c === 0 && m.d === 1) { - el.style.removeProperty("transform"); - } else { - el.style.setProperty("transform", m.toString()); - } - } catch { - /* non-parseable transform — leave as-is */ - } - }; - - const reapplyAll = (): void => { - const offsetEls = document.querySelectorAll("[" + PATH_OFFSET_ATTR + '="true"]'); - for (let i = 0; i < offsetEls.length; i++) { - const el = offsetEls[i] as HTMLElement; - if (!(el instanceof HTMLElement)) continue; - const x = el.style.getPropertyValue(OFFSET_X_PROP); - const y = el.style.getPropertyValue(OFFSET_Y_PROP); - if (x || y) { - el.style.setProperty( - "translate", - composeTranslate( - el, - "var(" + OFFSET_X_PROP + ", 0px)", - "var(" + OFFSET_Y_PROP + ", 0px)", - ), - ); - stripGsapTranslateFromTransform(el); - } - } - const boxSizeEls = document.querySelectorAll("[" + BOX_SIZE_ATTR + '="true"]'); - for (let i = 0; i < boxSizeEls.length; i++) { - const el = boxSizeEls[i] as HTMLElement; - if (!(el instanceof HTMLElement)) continue; - const w = el.style.getPropertyValue(WIDTH_PROP); - const h = el.style.getPropertyValue(HEIGHT_PROP); - if (w) el.style.setProperty("width", w); - if (h) el.style.setProperty("height", h); - } - const rotEls = document.querySelectorAll("[" + ROTATION_ATTR + '="true"]'); - for (let i = 0; i < rotEls.length; i++) { - const el = rotEls[i] as HTMLElement; - if (!(el instanceof HTMLElement)) continue; - const rot = el.style.getPropertyValue(ROTATION_PROP); - if (rot) { - el.style.setProperty("rotate", composeRotation(el, "var(" + ROTATION_PROP + ", 0deg)")); - stripGsapTranslateFromTransform(el); - } - } - reapplyMotionTimeline(); - }; - - const runtimeWindow = window as Window & { - __hf?: Record; - __player?: Record; - }; - - const isWrapped = (fn: (time: number) => unknown): boolean => - Boolean((fn as unknown as Record)[WRAPPED_PROP]); - - const markWrapped = (fn: (time: number) => unknown): void => { - try { - Object.defineProperty(fn, WRAPPED_PROP, { - configurable: false, - enumerable: false, - value: true, - }); - } catch { - try { - (fn as unknown as Record)[WRAPPED_PROP] = true; - } catch { - /* ignore */ - } - } - }; - - const wrapFn = (get: () => unknown, set: (fn: (time: number) => unknown) => void): boolean => { - const fn = get(); - if (typeof fn !== "function") return false; - const seek = fn as (time: number) => unknown; - if (isWrapped(seek)) { - reapplyAll(); - return true; - } - const wrapped = function (this: unknown, time: number): unknown { - lastSeekTime = typeof time === "number" && Number.isFinite(time) ? Math.max(0, time) : 0; - const result = seek.call(this, time); - reapplyAll(); - return result; - }; - markWrapped(wrapped); - set(wrapped); - reapplyAll(); - return true; - }; - - const wrapSeekFunctions = (): boolean => { - const a = wrapFn( - () => runtimeWindow.__hf?.["seek"], - (fn) => { - if (runtimeWindow.__hf) runtimeWindow.__hf["seek"] = fn; - }, - ); - const b = wrapFn( - () => runtimeWindow.__player?.["renderSeek"], - (fn) => { - if (runtimeWindow.__player) runtimeWindow.__player["renderSeek"] = fn; - }, - ); - return a || b; - }; - - const installSeekTrap = ( - obj: Record | undefined, - key: string, - getter: () => unknown, - setter: (fn: (time: number) => unknown) => void, - ): void => { - if (!obj) return; - try { - let current = obj[key]; - Object.defineProperty(obj, key, { - configurable: true, - enumerable: true, - get() { - return current; - }, - set(value: unknown) { - current = value; - if (typeof value === "function" && !isWrapped(value as (time: number) => unknown)) { - wrapFn(getter, setter); - } - }, - }); - } catch { - /* non-configurable — fall back to polling */ - } - }; - - if (document.readyState === "loading") { - document.addEventListener("DOMContentLoaded", () => reapplyAll(), { once: true }); - } else { - reapplyAll(); - } - - wrapSeekFunctions(); - installSeekTrap( - runtimeWindow.__hf, - "seek", - () => runtimeWindow.__hf?.["seek"], - (fn) => { - if (runtimeWindow.__hf) runtimeWindow.__hf["seek"] = fn; - }, - ); - installSeekTrap( - runtimeWindow.__player as Record | undefined, - "renderSeek", - () => runtimeWindow.__player?.["renderSeek"], - (fn) => { - if (runtimeWindow.__player) runtimeWindow.__player["renderSeek"] = fn; - }, - ); - let remaining = 120; - const interval = setInterval(() => { - wrapSeekFunctions(); - remaining -= 1; - if (remaining <= 0) clearInterval(interval); - }, 50); -} - -function studioManualEditsRenderRuntime( - manifestContent: string, - activeCompositionPath: string | null, -): void { - const OFFSET_X_PROP = "--hf-studio-offset-x"; - const OFFSET_Y_PROP = "--hf-studio-offset-y"; - const WIDTH_PROP = "--hf-studio-width"; - const HEIGHT_PROP = "--hf-studio-height"; - const ROTATION_PROP = "--hf-studio-rotation"; - const PATH_OFFSET_ATTR = "data-hf-studio-path-offset"; - const BOX_SIZE_ATTR = "data-hf-studio-box-size"; - const ROTATION_ATTR = "data-hf-studio-rotation"; - const ORIGINAL_TRANSLATE_ATTR = "data-hf-studio-original-translate"; - const ORIGINAL_ROTATE_ATTR = "data-hf-studio-original-rotate"; - const WRAPPED_SEEK_PROP = "__hfStudioManualEditsWrapped"; - const ROTATION_TRANSFORM_ORIGIN = "center center"; - - const finiteNumber = (value: unknown): number | null => - typeof value === "number" && Number.isFinite(value) ? value : null; - - const objectRecord = (value: unknown): Record | null => - value && typeof value === "object" ? (value as Record) : null; - - const runtimeWindow = window as Window & { - __hf?: { seek?: (time: number) => unknown }; - __hfStudioManualEditsApply?: () => number; - __player?: { renderSeek?: (time: number) => unknown }; - }; - - const parsedManifest = (() => { - try { - return objectRecord(JSON.parse(manifestContent)); - } catch { - return null; - } - })(); - const manifestEdits = Array.isArray(parsedManifest?.edits) ? parsedManifest.edits : []; - if (manifestEdits.length === 0) return; - - const sourceFileForElement = (element: HTMLElement): string => { - let current: HTMLElement | null = element; - while (current) { - const sourceFile = - current.getAttribute("data-composition-file") ?? - current.getAttribute("data-composition-src"); - if (sourceFile) return sourceFile; - current = current.parentElement; - } - return activeCompositionPath ?? "index.html"; - }; - - const elementMatchesSourceFile = (element: HTMLElement, sourceFile: string): boolean => - sourceFileForElement(element) === sourceFile; - - const styleUsesStudioOffset = (value: string): boolean => - value.includes(OFFSET_X_PROP) || value.includes(OFFSET_Y_PROP); - - const styleUsesStudioRotation = (value: string): boolean => value.includes(ROTATION_PROP); - - const splitTopLevelWhitespace = (value: string): string[] => { - const parts: string[] = []; - let depth = 0; - let current = ""; - for (const char of value.trim()) { - if (char === "(") depth += 1; - if (char === ")") depth = Math.max(0, depth - 1); - if (/\s/.test(char) && depth === 0) { - if (current) parts.push(current); - current = ""; - } else { - current += char; - } - } - if (current) parts.push(current); - return parts; - }; - - const composeTranslate = (element: HTMLElement, x: string, y: string): string => { - const original = element.getAttribute(ORIGINAL_TRANSLATE_ATTR)?.trim(); - if (!original || original === "none") return `${x} ${y}`; - - const parts = splitTopLevelWhitespace(original); - if (parts.length === 1) return `calc(${parts[0]} + ${x}) ${y}`; - if (parts.length === 2) return `calc(${parts[0]} + ${x}) calc(${parts[1]} + ${y})`; - if (parts.length === 3) { - return `calc(${parts[0]} + ${x}) calc(${parts[1]} + ${y}) ${parts[2]}`; - } - return `${x} ${y}`; - }; - - const readStyleOrComputed = (element: HTMLElement, property: string): string => { - try { - return ( - element.style.getPropertyValue(property) || - getComputedStyle(element).getPropertyValue(property) - ); - } catch { - return element.style.getPropertyValue(property); - } - }; - - const readTransformLonghandBase = ( - element: HTMLElement, - property: "translate" | "rotate", - ): string => { - const value = readStyleOrComputed(element, property).trim(); - return value === "none" ? "" : value; - }; - - const preparePathOffsetBase = (element: HTMLElement): void => { - const currentTranslate = readTransformLonghandBase(element, "translate"); - const hasMarker = element.hasAttribute(PATH_OFFSET_ATTR); - const wasResetByAnimation = !styleUsesStudioOffset(currentTranslate); - if (!hasMarker) { - element.setAttribute(ORIGINAL_TRANSLATE_ATTR, wasResetByAnimation ? currentTranslate : ""); - } else if (wasResetByAnimation) { - element.setAttribute(ORIGINAL_TRANSLATE_ATTR, currentTranslate); - } - }; - - const prepareRotationBase = (element: HTMLElement): void => { - const currentRotate = readTransformLonghandBase(element, "rotate"); - const hasMarker = element.hasAttribute(ROTATION_ATTR); - const wasResetByAnimation = !styleUsesStudioRotation(currentRotate); - if (!hasMarker) { - element.setAttribute(ORIGINAL_ROTATE_ATTR, wasResetByAnimation ? currentRotate : ""); - } else if (wasResetByAnimation) { - element.setAttribute(ORIGINAL_ROTATE_ATTR, currentRotate); - } - }; - - const querySelectorCandidates = (selector: string): HTMLElement[] => { - const isCandidate = (element: Element): element is HTMLElement => - element instanceof HTMLElement; - - const className = selector.match(/^\.([A-Za-z0-9_-]+)$/)?.[1]; - if (className) { - return Array.from(document.getElementsByTagName("*")).filter( - (element): element is HTMLElement => - isCandidate(element) && element.classList.contains(className), - ); - } - - if (/^[A-Za-z][A-Za-z0-9-]*$/.test(selector)) { - return Array.from(document.getElementsByTagName(selector)).filter(isCandidate); - } - - return Array.from(document.querySelectorAll(selector)).filter(isCandidate); - }; - - const resolveTarget = (edit: Record): HTMLElement | null => { - const targetRecord = objectRecord(edit.target); - if (!targetRecord) return null; - - const sourceFile = typeof targetRecord.sourceFile === "string" ? targetRecord.sourceFile : ""; - if (!sourceFile) return null; - - const id = typeof targetRecord.id === "string" ? targetRecord.id : ""; - if (id) { - const byId = document.getElementById(id); - if (byId instanceof HTMLElement && elementMatchesSourceFile(byId, sourceFile)) return byId; - - const matchesById = [ - document.documentElement, - ...Array.from(document.getElementsByTagName("*")), - ].filter( - (element): element is HTMLElement => - element instanceof HTMLElement && - element.id === id && - elementMatchesSourceFile(element, sourceFile), - ); - if (matchesById[0]) return matchesById[0]; - } - - const selector = typeof targetRecord.selector === "string" ? targetRecord.selector : ""; - if (!selector) return null; - - try { - const matches = querySelectorCandidates(selector).filter((element) => - elementMatchesSourceFile(element, sourceFile), - ); - const selectorIndex = finiteNumber(targetRecord.selectorIndex) ?? 0; - return matches[Math.max(0, Math.floor(selectorIndex))] ?? null; - } catch { - return null; - } - }; - - const roundRotationAngle = (angle: number): number => Math.round(angle * 10) / 10; - - const isSimpleRotateAngle = (value: string): boolean => - /^-?(?:\d+(?:\.\d+)?|\.\d+)(?:deg|rad|turn|grad)$/.test(value.trim()); - - const composeRotation = (element: HTMLElement, rotationValue: string): string => { - const original = element.getAttribute(ORIGINAL_ROTATE_ATTR)?.trim(); - if (!original || original === "none" || !isSimpleRotateAngle(original)) { - return rotationValue; - } - return `calc(${original} + ${rotationValue})`; - }; - - const applyPathOffset = (element: HTMLElement, edit: Record): void => { - const x = finiteNumber(edit.x); - const y = finiteNumber(edit.y); - if (x == null || y == null) return; - preparePathOffsetBase(element); - element.setAttribute(PATH_OFFSET_ATTR, "true"); - element.style.setProperty(OFFSET_X_PROP, `${Math.round(x)}px`); - element.style.setProperty(OFFSET_Y_PROP, `${Math.round(y)}px`); - element.style.setProperty( - "translate", - composeTranslate(element, `var(${OFFSET_X_PROP}, 0px)`, `var(${OFFSET_Y_PROP}, 0px)`), - ); - }; - - const readParentFlexBasisPixels = ( - element: HTMLElement, - size: { width: number; height: number }, - ): number | null => { - const parent = element.parentElement; - if (!parent) return null; - const styles = getComputedStyle(parent); - if (styles.display !== "flex" && styles.display !== "inline-flex") return null; - return Math.round( - Math.max(1, styles.flexDirection.startsWith("column") ? size.height : size.width), - ); - }; - - const applyBoxSize = (element: HTMLElement, edit: Record): void => { - const width = finiteNumber(edit.width); - const height = finiteNumber(edit.height); - if (width == null || height == null || width <= 0 || height <= 0) return; - - const rounded = { - width: Math.round(Math.max(1, width)), - height: Math.round(Math.max(1, height)), - }; - element.setAttribute(BOX_SIZE_ATTR, "true"); - element.style.setProperty(WIDTH_PROP, `${rounded.width}px`); - element.style.setProperty(HEIGHT_PROP, `${rounded.height}px`); - element.style.setProperty("box-sizing", "border-box"); - element.style.setProperty("width", `${rounded.width}px`); - element.style.setProperty("height", `${rounded.height}px`); - element.style.setProperty("min-width", "0px"); - element.style.setProperty("min-height", "0px"); - element.style.setProperty("max-width", "none"); - element.style.setProperty("max-height", "none"); - - const flexBasis = readParentFlexBasisPixels(element, rounded); - if (flexBasis != null) { - element.style.setProperty("flex-basis", `${flexBasis}px`); - element.style.setProperty("flex-grow", "0"); - element.style.setProperty("flex-shrink", "0"); - } - if (getComputedStyle(element).display === "inline") { - element.style.setProperty("display", "inline-block"); - } - }; - - const applyRotation = (element: HTMLElement, edit: Record): void => { - const angle = finiteNumber(edit.angle); - if (angle == null) return; - prepareRotationBase(element); - element.setAttribute(ROTATION_ATTR, "true"); - element.style.setProperty(ROTATION_PROP, `${roundRotationAngle(angle)}deg`); - element.style.setProperty("transform-origin", ROTATION_TRANSFORM_ORIGIN); - element.style.setProperty("rotate", composeRotation(element, `var(${ROTATION_PROP}, 0deg)`)); - }; - - const applyManifest = (): number => { - let applied = 0; - for (const edit of manifestEdits) { - const editRecord = objectRecord(edit); - if (!editRecord) continue; - const element = resolveTarget(editRecord); - if (!element) continue; - if (editRecord.kind === "path-offset") applyPathOffset(element, editRecord); - if (editRecord.kind === "box-size") applyBoxSize(element, editRecord); - if (editRecord.kind === "rotation") applyRotation(element, editRecord); - applied += 1; - } - return applied; - }; - runtimeWindow.__hfStudioManualEditsApply = applyManifest; - - const markWrapped = (fn: (time: number) => unknown): void => { - try { - Object.defineProperty(fn, WRAPPED_SEEK_PROP, { - configurable: false, - enumerable: false, - value: true, - }); - } catch { - try { - (fn as unknown as Record)[WRAPPED_SEEK_PROP] = true; - } catch { - // Ignore non-extensible functions. - } - } - }; - - const isWrapped = (fn: (time: number) => unknown): boolean => - Boolean((fn as unknown as Record)[WRAPPED_SEEK_PROP]); - - const wrapFunction = ( - get: () => ((time: number) => unknown) | undefined, - set: (fn: (time: number) => unknown) => void, - ): boolean => { - const fn = get(); - if (!fn) return false; - const seek = fn as (time: number) => unknown; - if (isWrapped(seek)) { - applyManifest(); - return true; - } - - const wrappedSeek = function (this: unknown, time: number): unknown { - const result = seek.call(this, time); - applyManifest(); - return result; - }; - markWrapped(wrappedSeek); - set(wrappedSeek); - applyManifest(); - return true; - }; - - const wrapSeekFunctions = (): boolean => { - const wrappedHfSeek = wrapFunction( - () => runtimeWindow.__hf?.seek, - (fn) => { - if (runtimeWindow.__hf) runtimeWindow.__hf.seek = fn; - }, - ); - const wrappedPlayerRenderSeek = wrapFunction( - () => runtimeWindow.__player?.renderSeek, - (fn) => { - if (runtimeWindow.__player) runtimeWindow.__player.renderSeek = fn; - }, - ); - return wrappedHfSeek || wrappedPlayerRenderSeek; - }; - - if (document.readyState === "loading") { - document.addEventListener("DOMContentLoaded", () => applyManifest(), { once: true }); - } else { - applyManifest(); - } - - wrapSeekFunctions(); - let remainingSeekWrapAttempts = 120; - const seekWrapInterval = setInterval(() => { - wrapSeekFunctions(); - remainingSeekWrapAttempts -= 1; - if (remainingSeekWrapAttempts <= 0) clearInterval(seekWrapInterval); - }, 50); -} +/** @deprecated Import from @hyperframes/studio-server/manual-edits-render-script */ +export * from "@hyperframes/studio-server/manual-edits-render-script"; diff --git a/packages/core/src/studio-api/helpers/screenshotClip.ts b/packages/core/src/studio-api/helpers/screenshotClip.ts index a1db59033e..5c47610345 100644 --- a/packages/core/src/studio-api/helpers/screenshotClip.ts +++ b/packages/core/src/studio-api/helpers/screenshotClip.ts @@ -1,31 +1,2 @@ -export interface ScreenshotClip { - x: number; - y: number; - width: number; - height: number; -} - -export function getElementScreenshotClip( - selector: string, - selectorIndex?: number, -): ScreenshotClip | undefined { - const matches = Array.from(document.querySelectorAll(selector)).filter( - (el): el is HTMLElement => el instanceof HTMLElement, - ); - const safeIndex = Math.max(0, Math.min(matches.length - 1, Math.floor(selectorIndex ?? 0))); - const el = matches[safeIndex] ?? null; - if (!(el instanceof HTMLElement)) return undefined; - const rect = el.getBoundingClientRect(); - if (rect.width < 4 || rect.height < 4) return undefined; - const pad = 8; - const x = Math.max(0, rect.left - pad); - const y = Math.max(0, rect.top - pad); - const maxWidth = window.innerWidth - x; - const maxHeight = window.innerHeight - y; - return { - x, - y, - width: Math.max(1, Math.min(rect.width + pad * 2, maxWidth)), - height: Math.max(1, Math.min(rect.height + pad * 2, maxHeight)), - }; -} +/** @deprecated Import from @hyperframes/studio-server/screenshot-clip */ +export * from "@hyperframes/studio-server/screenshot-clip"; diff --git a/packages/core/src/studio-api/helpers/studioMotionRenderScript.ts b/packages/core/src/studio-api/helpers/studioMotionRenderScript.ts index 87d2b815ef..81b0803f74 100644 --- a/packages/core/src/studio-api/helpers/studioMotionRenderScript.ts +++ b/packages/core/src/studio-api/helpers/studioMotionRenderScript.ts @@ -1,260 +1,2 @@ -export interface StudioMotionRenderScriptOptions { - activeCompositionPath?: string | null; -} - -export const STUDIO_MOTION_PATH = ".hyperframes/studio-motion.json"; - -function hasStudioMotionEntries(manifestContent: string): boolean { - try { - const parsed = JSON.parse(manifestContent) as { motions?: unknown }; - return Array.isArray(parsed.motions) && parsed.motions.length > 0; - } catch { - return false; - } -} - -/** - * Builds the render-time Studio motion runtime script, or null when no owned motion exists. - */ -export function createStudioMotionRenderBodyScript( - manifestContent: string, - options: StudioMotionRenderScriptOptions = {}, -): string | null { - if (!manifestContent.trim() || !hasStudioMotionEntries(manifestContent)) return null; - return `(${studioMotionRenderRuntime.toString()})(${JSON.stringify(manifestContent)}, ${JSON.stringify(options.activeCompositionPath ?? null)});`; -} - -function studioMotionRenderRuntime( - manifestContent: string, - activeCompositionPath: string | null, -): void { - const STUDIO_MOTION_TIMELINE_ID = "studio-motion"; - const STUDIO_MOTION_ATTR = "data-hf-studio-motion"; - const ORIGINAL_TRANSFORM_ATTR = "data-hf-studio-motion-original-transform"; - const ORIGINAL_OPACITY_ATTR = "data-hf-studio-motion-original-opacity"; - const ORIGINAL_VISIBILITY_ATTR = "data-hf-studio-motion-original-visibility"; - - const objectRecord = (value: unknown): Record | null => - value && typeof value === "object" ? (value as Record) : null; - - const finiteNumber = (value: unknown): number | null => - typeof value === "number" && Number.isFinite(value) ? value : null; - - const runtimeWindow = window as Window & { - gsap?: { - timeline?: (vars?: Record) => { - fromTo?: ( - target: HTMLElement, - from: Record, - to: Record, - at: number, - ) => unknown; - totalTime?: (time: number, suppressEvents?: boolean) => unknown; - time?: (time: number) => unknown; - pause?: () => unknown; - kill?: () => unknown; - }; - set?: (target: HTMLElement, vars: Record) => unknown; - registerPlugin?: (...plugins: unknown[]) => unknown; - }; - CustomEase?: { create?: (id: string, data: string) => unknown }; - __player?: { getTime?: () => number }; - __timeline?: { time?: () => number }; - __timelines?: Record< - string, - | { - kill?: () => unknown; - } - | undefined - >; - __hfStudioMotionApply?: () => number; - }; - - const parseMotionValues = (value: unknown): Record | null => { - const record = objectRecord(value); - if (!record) return null; - const parsed: Record = {}; - for (const key of ["x", "y", "scale", "rotation", "opacity", "autoAlpha"]) { - const next = finiteNumber(record[key]); - if (next != null) parsed[key] = next; - } - return Object.keys(parsed).length > 0 ? parsed : null; - }; - - const parseCustomEase = (value: unknown): { id: string; data: string } | null => { - const record = objectRecord(value); - if (!record) return null; - const id = typeof record.id === "string" ? record.id.trim() : ""; - const data = typeof record.data === "string" ? record.data.trim() : ""; - if (!id || !data) return null; - return { id, data }; - }; - - const parsedManifest = (() => { - try { - return objectRecord(JSON.parse(manifestContent)); - } catch { - return null; - } - })(); - const manifestMotions = Array.isArray(parsedManifest?.motions) ? parsedManifest.motions : []; - - const sourceFileForElement = (element: HTMLElement): string => { - let current: HTMLElement | null = element; - while (current) { - const sourceFile = - current.getAttribute("data-composition-file") ?? - current.getAttribute("data-composition-src"); - if (sourceFile) return sourceFile; - current = current.parentElement; - } - return activeCompositionPath ?? "index.html"; - }; - - const elementMatchesSourceFile = (element: HTMLElement, sourceFile: string): boolean => - sourceFileForElement(element) === sourceFile; - - const isHTMLElement = (element: Element | null): element is HTMLElement => - element instanceof HTMLElement; - - const querySelectorCandidates = (selector: string): HTMLElement[] => { - const className = selector.match(/^\.([A-Za-z0-9_-]+)$/)?.[1]; - if (className) { - return Array.from(document.getElementsByTagName("*")).filter( - (element): element is HTMLElement => - isHTMLElement(element) && element.classList.contains(className), - ); - } - if (/^[A-Za-z][A-Za-z0-9-]*$/.test(selector)) { - return Array.from(document.getElementsByTagName(selector)).filter(isHTMLElement); - } - return Array.from(document.querySelectorAll(selector)).filter(isHTMLElement); - }; - - const resolveTarget = (targetRecord: Record): HTMLElement | null => { - const sourceFile = typeof targetRecord.sourceFile === "string" ? targetRecord.sourceFile : ""; - if (!sourceFile) return null; - const id = typeof targetRecord.id === "string" ? targetRecord.id : ""; - if (id) { - const byId = document.getElementById(id); - if (isHTMLElement(byId) && elementMatchesSourceFile(byId, sourceFile)) return byId; - } - const selector = typeof targetRecord.selector === "string" ? targetRecord.selector : ""; - if (!selector) return null; - try { - const selectorIndex = Math.max(0, Math.floor(finiteNumber(targetRecord.selectorIndex) ?? 0)); - return ( - querySelectorCandidates(selector).filter((element) => - elementMatchesSourceFile(element, sourceFile), - )[selectorIndex] ?? null - ); - } catch { - return null; - } - }; - - const restoreElement = (element: HTMLElement): void => { - runtimeWindow.gsap?.set?.(element, { clearProps: "transform,opacity,visibility" }); - element.style.transform = element.getAttribute(ORIGINAL_TRANSFORM_ATTR) ?? ""; - element.style.opacity = element.getAttribute(ORIGINAL_OPACITY_ATTR) ?? ""; - element.style.visibility = element.getAttribute(ORIGINAL_VISIBILITY_ATTR) ?? ""; - element.removeAttribute(STUDIO_MOTION_ATTR); - element.removeAttribute(ORIGINAL_TRANSFORM_ATTR); - element.removeAttribute(ORIGINAL_OPACITY_ATTR); - element.removeAttribute(ORIGINAL_VISIBILITY_ATTR); - }; - - const restoreStudioMotionElements = (): void => { - for (const element of Array.from(document.querySelectorAll(`[${STUDIO_MOTION_ATTR}]`))) { - if (isHTMLElement(element)) restoreElement(element); - } - }; - - const readCurrentTime = (): number => { - try { - const playerTime = runtimeWindow.__player?.getTime?.(); - if (typeof playerTime === "number" && Number.isFinite(playerTime)) { - return Math.max(0, playerTime); - } - } catch { - // fall through - } - try { - const timelineTime = runtimeWindow.__timeline?.time?.(); - if (typeof timelineTime === "number" && Number.isFinite(timelineTime)) { - return Math.max(0, timelineTime); - } - } catch { - // fall through - } - return 0; - }; - - const resolveEase = (motion: Record): string => { - const fallback = - typeof motion.ease === "string" && motion.ease.trim() ? motion.ease.trim() : "none"; - const customEase = parseCustomEase(motion.customEase); - const customEasePlugin = runtimeWindow.CustomEase; - if (!customEase || typeof customEasePlugin?.create !== "function") return fallback; - try { - runtimeWindow.gsap?.registerPlugin?.(customEasePlugin); - customEasePlugin.create(customEase.id, customEase.data); - return customEase.id; - } catch { - return fallback; - } - }; - - const applyManifest = (): number => { - runtimeWindow.__timelines = runtimeWindow.__timelines ?? {}; - runtimeWindow.__timelines[STUDIO_MOTION_TIMELINE_ID]?.kill?.(); - delete runtimeWindow.__timelines[STUDIO_MOTION_TIMELINE_ID]; - restoreStudioMotionElements(); - const gsap = runtimeWindow.gsap; - if (!gsap?.timeline || manifestMotions.length === 0) return 0; - - const timeline = gsap.timeline({ paused: true, defaults: { overwrite: "auto" } }); - let applied = 0; - for (const motionValue of manifestMotions) { - const motion = objectRecord(motionValue); - if (!motion || motion.kind !== "gsap-motion") continue; - const targetRecord = objectRecord(motion.target); - if (!targetRecord) continue; - const target = resolveTarget(targetRecord); - if (!target || typeof timeline.fromTo !== "function") continue; - const start = finiteNumber(motion.start); - const duration = finiteNumber(motion.duration); - if (start == null || duration == null || start < 0 || duration <= 0) continue; - const from = parseMotionValues(motion.from); - const to = parseMotionValues(motion.to); - if (!from || !to) continue; - if (!target.hasAttribute(STUDIO_MOTION_ATTR)) { - target.setAttribute(ORIGINAL_TRANSFORM_ATTR, target.style.transform); - target.setAttribute(ORIGINAL_OPACITY_ATTR, target.style.opacity); - target.setAttribute(ORIGINAL_VISIBILITY_ATTR, target.style.visibility); - } - target.setAttribute(STUDIO_MOTION_ATTR, "true"); - timeline.fromTo( - target, - from, - { ...to, duration, ease: resolveEase(motion), overwrite: "auto", immediateRender: false }, - start, - ); - applied += 1; - } - - if (applied === 0) { - timeline.kill?.(); - return 0; - } - runtimeWindow.__timelines[STUDIO_MOTION_TIMELINE_ID] = timeline; - timeline.pause?.(); - const currentTime = readCurrentTime(); - if (typeof timeline.totalTime === "function") timeline.totalTime(currentTime, false); - else timeline.time?.(currentTime); - return applied; - }; - - runtimeWindow.__hfStudioMotionApply = applyManifest; - applyManifest(); -} +/** @deprecated Import from @hyperframes/studio-server/studio-motion-render-script */ +export * from "@hyperframes/studio-server/studio-motion-render-script"; diff --git a/packages/core/src/studio-api/index.ts b/packages/core/src/studio-api/index.ts index bbc77f69d6..9e3e76b6a7 100644 --- a/packages/core/src/studio-api/index.ts +++ b/packages/core/src/studio-api/index.ts @@ -1,18 +1,2 @@ -export { createStudioApi } from "./createStudioApi.js"; -export { createProjectSignature } from "./helpers/projectSignature.js"; -export type { StudioApiAdapter, ResolvedProject, RenderJobState, LintResult } from "./types.js"; -export { isSafePath, walkDir } from "./helpers/safePath.js"; -export { getMimeType, MIME_TYPES } from "./helpers/mime.js"; -export { buildSubCompositionHtml } from "./helpers/subComposition.js"; -export { getElementScreenshotClip, type ScreenshotClip } from "./helpers/screenshotClip.js"; -export { - STUDIO_MANUAL_EDITS_PATH, - createStudioManualEditsRenderBodyScript, - createStudioPositionSeekReapplyScript, - type StudioManualEditsRenderScriptOptions, -} from "./helpers/manualEditsRenderScript.js"; -export { - STUDIO_MOTION_PATH, - createStudioMotionRenderBodyScript, - type StudioMotionRenderScriptOptions, -} from "./helpers/studioMotionRenderScript.js"; +/** @deprecated Import from @hyperframes/studio-server */ +export * from "@hyperframes/studio-server"; diff --git a/packages/studio-server/package.json b/packages/studio-server/package.json new file mode 100644 index 0000000000..f9e249ecff --- /dev/null +++ b/packages/studio-server/package.json @@ -0,0 +1,97 @@ +{ + "name": "@hyperframes/studio-server", + "version": "0.7.11", + "repository": { + "type": "git", + "url": "https://github.com/heygen-com/hyperframes", + "directory": "packages/studio-server" + }, + "files": [ + "dist", + "README.md" + ], + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "import": "./src/index.ts", + "types": "./src/index.ts" + }, + "./package.json": "./package.json", + "./screenshot-clip": { + "import": "./src/helpers/screenshotClip.ts", + "types": "./src/helpers/screenshotClip.ts" + }, + "./manual-edits-render-script": { + "import": "./src/helpers/manualEditsRenderScript.ts", + "types": "./src/helpers/manualEditsRenderScript.ts" + }, + "./studio-motion-render-script": { + "import": "./src/helpers/studioMotionRenderScript.ts", + "types": "./src/helpers/studioMotionRenderScript.ts" + }, + "./draft-markers": { + "import": "./src/helpers/draftMarkers.ts", + "types": "./src/helpers/draftMarkers.ts" + }, + "./finite-mutation": { + "import": "./src/helpers/finiteMutation.ts", + "types": "./src/helpers/finiteMutation.ts" + } + }, + "publishConfig": { + "access": "public", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "./package.json": "./package.json", + "./screenshot-clip": { + "import": "./dist/helpers/screenshotClip.js", + "types": "./dist/helpers/screenshotClip.d.ts" + }, + "./manual-edits-render-script": { + "import": "./dist/helpers/manualEditsRenderScript.js", + "types": "./dist/helpers/manualEditsRenderScript.d.ts" + }, + "./studio-motion-render-script": { + "import": "./dist/helpers/studioMotionRenderScript.js", + "types": "./dist/helpers/studioMotionRenderScript.d.ts" + }, + "./draft-markers": { + "import": "./dist/helpers/draftMarkers.js", + "types": "./dist/helpers/draftMarkers.d.ts" + }, + "./finite-mutation": { + "import": "./dist/helpers/finiteMutation.js", + "types": "./dist/helpers/finiteMutation.d.ts" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "scripts": { + "build": "tsup", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit", + "prepublishOnly": "echo skip" + }, + "dependencies": { + "@hyperframes/core": "workspace:*", + "@hyperframes/parsers": "workspace:*", + "hono": "^4.0.0", + "linkedom": "^0.18.12", + "postcss": "^8.5.8", + "postcss-selector-parser": "^7.1.2" + }, + "devDependencies": { + "@types/node": "^25.0.10", + "tsup": "^8.0.0", + "tsx": "^4.21.0", + "typescript": "^5.0.0", + "vitest": "^3.2.4" + } +} diff --git a/packages/core/src/studio-api/createStudioApi.ts b/packages/studio-server/src/createStudioApi.ts similarity index 100% rename from packages/core/src/studio-api/createStudioApi.ts rename to packages/studio-server/src/createStudioApi.ts diff --git a/packages/core/src/studio-api/helpers/backupJournal.test.ts b/packages/studio-server/src/helpers/backupJournal.test.ts similarity index 100% rename from packages/core/src/studio-api/helpers/backupJournal.test.ts rename to packages/studio-server/src/helpers/backupJournal.test.ts diff --git a/packages/core/src/studio-api/helpers/backupJournal.ts b/packages/studio-server/src/helpers/backupJournal.ts similarity index 100% rename from packages/core/src/studio-api/helpers/backupJournal.ts rename to packages/studio-server/src/helpers/backupJournal.ts diff --git a/packages/studio-server/src/helpers/draftMarkers.ts b/packages/studio-server/src/helpers/draftMarkers.ts new file mode 100644 index 0000000000..38bc61861f --- /dev/null +++ b/packages/studio-server/src/helpers/draftMarkers.ts @@ -0,0 +1,10 @@ +/** + * Draft-marker constants shared between core's PreviewAdapter and Studio's + * manual-edits code. CSS custom properties written during a drag gesture, plus + * the gesture marker attribute. Exported from @hyperframes/core/studio-api/draft-markers. + */ +export const STUDIO_OFFSET_X_PROP = "--hf-studio-offset-x"; +export const STUDIO_OFFSET_Y_PROP = "--hf-studio-offset-y"; +export const STUDIO_WIDTH_PROP = "--hf-studio-width"; +export const STUDIO_HEIGHT_PROP = "--hf-studio-height"; +export const STUDIO_MANUAL_EDIT_GESTURE_ATTR = "data-hf-studio-manual-edit-gesture"; diff --git a/packages/core/src/studio-api/helpers/finiteMutation.test.ts b/packages/studio-server/src/helpers/finiteMutation.test.ts similarity index 100% rename from packages/core/src/studio-api/helpers/finiteMutation.test.ts rename to packages/studio-server/src/helpers/finiteMutation.test.ts diff --git a/packages/studio-server/src/helpers/finiteMutation.ts b/packages/studio-server/src/helpers/finiteMutation.ts new file mode 100644 index 0000000000..1e145e4fae --- /dev/null +++ b/packages/studio-server/src/helpers/finiteMutation.ts @@ -0,0 +1,38 @@ +export interface UnsafeMutationValue { + path: string; + reason: "non-finite-number" | "null"; +} + +interface FindUnsafeMutationValuesOptions { + allowNullPath?: (path: string) => boolean; +} + +export function findUnsafeMutationValues( + value: unknown, + path = "body", + options: FindUnsafeMutationValuesOptions = {}, +): UnsafeMutationValue[] { + if (value === null) { + return options.allowNullPath?.(path) ? [] : [{ path, reason: "null" }]; + } + if (typeof value === "number") { + return Number.isFinite(value) ? [] : [{ path, reason: "non-finite-number" }]; + } + if (!value || typeof value !== "object") return []; + if (Array.isArray(value)) { + return value.flatMap((item, index) => + findUnsafeMutationValues(item, `${path}[${index}]`, options), + ); + } + return Object.entries(value).flatMap(([key, item]) => + findUnsafeMutationValues(item, `${path}.${key}`, options), + ); +} + +const DOM_PATCH_NULL_VALUE_PATH = /^body\.operations\[\d+\]\.value$/; + +export function findUnsafeDomPatchValues(value: unknown): UnsafeMutationValue[] { + return findUnsafeMutationValues(value, "body", { + allowNullPath: (path) => DOM_PATCH_NULL_VALUE_PATH.test(path), + }); +} diff --git a/packages/core/src/studio-api/helpers/hfIdPersist.test.ts b/packages/studio-server/src/helpers/hfIdPersist.test.ts similarity index 100% rename from packages/core/src/studio-api/helpers/hfIdPersist.test.ts rename to packages/studio-server/src/helpers/hfIdPersist.test.ts diff --git a/packages/core/src/studio-api/helpers/hfIdPersist.ts b/packages/studio-server/src/helpers/hfIdPersist.ts similarity index 96% rename from packages/core/src/studio-api/helpers/hfIdPersist.ts rename to packages/studio-server/src/helpers/hfIdPersist.ts index 22b8a27464..b60e21920b 100644 --- a/packages/core/src/studio-api/helpers/hfIdPersist.ts +++ b/packages/studio-server/src/helpers/hfIdPersist.ts @@ -1,4 +1,4 @@ -import { ensureHfIds } from "../../parsers/hfIds.js"; +import { ensureHfIds } from "@hyperframes/parsers/hf-ids"; import { readFileSync, writeFileSync } from "node:fs"; /** diff --git a/packages/core/src/studio-api/helpers/manualEditsRenderScript.test.ts b/packages/studio-server/src/helpers/manualEditsRenderScript.test.ts similarity index 100% rename from packages/core/src/studio-api/helpers/manualEditsRenderScript.test.ts rename to packages/studio-server/src/helpers/manualEditsRenderScript.test.ts diff --git a/packages/studio-server/src/helpers/manualEditsRenderScript.ts b/packages/studio-server/src/helpers/manualEditsRenderScript.ts new file mode 100644 index 0000000000..2d6dd39aa5 --- /dev/null +++ b/packages/studio-server/src/helpers/manualEditsRenderScript.ts @@ -0,0 +1,735 @@ +// fallow-ignore-file code-duplication +export interface StudioManualEditsRenderScriptOptions { + activeCompositionPath?: string | null; +} + +export const STUDIO_MANUAL_EDITS_PATH = ".hyperframes/studio-manual-edits.json"; + +export function createStudioManualEditsRenderBodyScript( + manifestContent: string, + options: StudioManualEditsRenderScriptOptions = {}, +): string | null { + if (!manifestContent.trim()) return null; + return `(${studioManualEditsRenderRuntime.toString()})(${JSON.stringify(manifestContent)}, ${JSON.stringify(options.activeCompositionPath ?? null)});`; +} + +/** + * Returns a self-contained IIFE string that re-applies studio position edits + * (translate, rotate) after every GSAP seek by querying data attributes baked + * into the HTML. Works without a JSON manifest — positions are already inlined + * as CSS custom properties on the elements. + */ +export function createStudioPositionSeekReapplyScript(): string { + return `(${studioPositionSeekReapplyRuntime.toString()})();`; +} + +function studioPositionSeekReapplyRuntime(): void { + const OFFSET_X_PROP = "--hf-studio-offset-x"; + const OFFSET_Y_PROP = "--hf-studio-offset-y"; + const WIDTH_PROP = "--hf-studio-width"; + const HEIGHT_PROP = "--hf-studio-height"; + const ROTATION_PROP = "--hf-studio-rotation"; + const PATH_OFFSET_ATTR = "data-hf-studio-path-offset"; + const BOX_SIZE_ATTR = "data-hf-studio-box-size"; + const ROTATION_ATTR = "data-hf-studio-rotation"; + const ORIGINAL_TRANSLATE_ATTR = "data-hf-studio-original-translate"; + const ORIGINAL_ROTATE_ATTR = "data-hf-studio-original-rotate"; + const MOTION_ATTR = "data-hf-studio-motion"; + const MOTION_TL_KEY = "studio-motion"; + const WRAPPED_PROP = "__hfStudioPositionSeekReapplyWrapped"; + + if ( + !document.querySelector("[" + PATH_OFFSET_ATTR + '="true"]') && + !document.querySelector("[" + BOX_SIZE_ATTR + '="true"]') && + !document.querySelector("[" + ROTATION_ATTR + '="true"]') && + !document.querySelector("[" + MOTION_ATTR + "]") + ) + return; + + const splitTopLevelWhitespace = (value: string): string[] => { + const parts: string[] = []; + let depth = 0; + let current = ""; + for (const char of value.trim()) { + if (char === "(") depth += 1; + if (char === ")") depth = Math.max(0, depth - 1); + if (/\s/.test(char) && depth === 0) { + if (current) parts.push(current); + current = ""; + } else { + current += char; + } + } + if (current) parts.push(current); + return parts; + }; + + const composeTranslate = (element: HTMLElement, x: string, y: string): string => { + const original = element.getAttribute(ORIGINAL_TRANSLATE_ATTR)?.trim(); + if (!original || original === "none") return x + " " + y; + const parts = splitTopLevelWhitespace(original); + if (parts.length === 1) return "calc(" + parts[0] + " + " + x + ") " + y; + if (parts.length >= 2) { + const z = parts.length >= 3 ? " " + parts[2] : ""; + return "calc(" + parts[0] + " + " + x + ") calc(" + parts[1] + " + " + y + ")" + z; + } + return x + " " + y; + }; + + const isSimpleRotateAngle = (value: string): boolean => + /^-?(?:\d+(?:\.\d+)?|\.\d+)(?:deg|rad|turn|grad)$/.test(value.trim()); + + const composeRotation = (element: HTMLElement, rotationValue: string): string => { + const original = element.getAttribute(ORIGINAL_ROTATE_ATTR)?.trim(); + if (!original || original === "none" || !isSimpleRotateAngle(original)) return rotationValue; + return "calc(" + original + " + " + rotationValue + ")"; + }; + + let lastSeekTime = 0; + let cachedMotionKey = ""; + + const finiteNum = (v: unknown): number | null => + typeof v === "number" && Number.isFinite(v) ? v : null; + + const computeMotionKey = (motionEls: NodeListOf): string => { + let key = ""; + for (let i = 0; i < motionEls.length; i++) { + const json = (motionEls[i] as HTMLElement).getAttribute?.(MOTION_ATTR); + if (json) key += (key ? "\n" : "") + json; + } + return key; + }; + + const reapplyMotionTimeline = (): void => { + const motionEls = document.querySelectorAll("[" + MOTION_ATTR + "]"); + if (motionEls.length === 0) { + cachedMotionKey = ""; + return; + } + const win = window as Window & { + gsap?: { + timeline?: (opts: Record) => Record; + set?: (el: HTMLElement, vars: Record) => void; + registerPlugin?: (plugin: unknown) => void; + }; + CustomEase?: { create?: (id: string, data: string) => void }; + __timelines?: Record>; + }; + const gsap = win.gsap; + if (!gsap || typeof gsap.timeline !== "function") return; + win.__timelines = win.__timelines || {}; + + // Cache the timeline keyed by the concatenated motion JSON strings. + // On each seek, if the key hasn't changed, just seek the existing timeline + // instead of rebuilding it (avoids kill+recreate on every frame). + const motionKey = computeMotionKey(motionEls); + const existing = win.__timelines[MOTION_TL_KEY]; + if ( + motionKey && + motionKey === cachedMotionKey && + existing && + typeof existing.totalTime === "function" + ) { + (existing.totalTime as (t: number, s: boolean) => void)(lastSeekTime, false); + return; + } + + if (existing && typeof existing.kill === "function") (existing.kill as () => void)(); + const tl = gsap.timeline({ paused: true, defaults: { overwrite: "auto" } }); + const fromTo = tl.fromTo as ( + el: HTMLElement, + from: Record, + to: Record, + pos: number, + ) => void; + if (typeof fromTo !== "function") return; + let applied = 0; + for (let i = 0; i < motionEls.length; i++) { + const el = motionEls[i] as HTMLElement; + if (!(el instanceof HTMLElement)) continue; + const json = el.getAttribute(MOTION_ATTR); + if (!json) continue; + try { + const m = JSON.parse(json) as Record; + const start = finiteNum(m.start); + const duration = finiteNum(m.duration); + if (start == null || duration == null || duration <= 0) continue; + const ease = typeof m.ease === "string" ? m.ease : "none"; + const from = (m.from && typeof m.from === "object" ? m.from : {}) as Record< + string, + unknown + >; + const to = (m.to && typeof m.to === "object" ? m.to : {}) as Record; + const customEase = m.customEase as { id?: string; data?: string } | null | undefined; + let resolvedEase = ease; + if (customEase?.id && customEase?.data && win.CustomEase?.create) { + try { + gsap.registerPlugin?.(win.CustomEase); + win.CustomEase.create(customEase.id, customEase.data); + resolvedEase = customEase.id; + } catch { + /* use default ease */ + } + } + fromTo.call( + tl, + el, + { ...from }, + { ...to, duration, ease: resolvedEase, overwrite: "auto", immediateRender: false }, + start, + ); + applied += 1; + } catch { + /* malformed JSON — skip */ + } + } + if (applied === 0) { + cachedMotionKey = ""; + if (typeof (tl as { kill?: () => void }).kill === "function") + (tl as { kill: () => void }).kill(); + return; + } + cachedMotionKey = motionKey; + win.__timelines[MOTION_TL_KEY] = tl; + if (typeof tl.pause === "function") (tl.pause as () => void)(); + if (typeof tl.totalTime === "function") + (tl.totalTime as (t: number, s: boolean) => void)(lastSeekTime, false); + }; + + const stripGsapTranslateFromTransform = (el: HTMLElement): void => { + const transform = el.style.getPropertyValue("transform"); + if (!transform || transform === "none") return; + const win = el.ownerDocument.defaultView as (Window & typeof globalThis) | null; + const MatrixCtor = (win as unknown as { DOMMatrix?: typeof DOMMatrix })?.DOMMatrix; + if (!MatrixCtor) return; + try { + const m = new MatrixCtor(transform); + if (m.m41 === 0 && m.m42 === 0) return; + m.m41 = 0; + m.m42 = 0; + if (m.is2D && m.a === 1 && m.b === 0 && m.c === 0 && m.d === 1) { + el.style.removeProperty("transform"); + } else { + el.style.setProperty("transform", m.toString()); + } + } catch { + /* non-parseable transform — leave as-is */ + } + }; + + const reapplyAll = (): void => { + const offsetEls = document.querySelectorAll("[" + PATH_OFFSET_ATTR + '="true"]'); + for (let i = 0; i < offsetEls.length; i++) { + const el = offsetEls[i] as HTMLElement; + if (!(el instanceof HTMLElement)) continue; + const x = el.style.getPropertyValue(OFFSET_X_PROP); + const y = el.style.getPropertyValue(OFFSET_Y_PROP); + if (x || y) { + el.style.setProperty( + "translate", + composeTranslate( + el, + "var(" + OFFSET_X_PROP + ", 0px)", + "var(" + OFFSET_Y_PROP + ", 0px)", + ), + ); + stripGsapTranslateFromTransform(el); + } + } + const boxSizeEls = document.querySelectorAll("[" + BOX_SIZE_ATTR + '="true"]'); + for (let i = 0; i < boxSizeEls.length; i++) { + const el = boxSizeEls[i] as HTMLElement; + if (!(el instanceof HTMLElement)) continue; + const w = el.style.getPropertyValue(WIDTH_PROP); + const h = el.style.getPropertyValue(HEIGHT_PROP); + if (w) el.style.setProperty("width", w); + if (h) el.style.setProperty("height", h); + } + const rotEls = document.querySelectorAll("[" + ROTATION_ATTR + '="true"]'); + for (let i = 0; i < rotEls.length; i++) { + const el = rotEls[i] as HTMLElement; + if (!(el instanceof HTMLElement)) continue; + const rot = el.style.getPropertyValue(ROTATION_PROP); + if (rot) { + el.style.setProperty("rotate", composeRotation(el, "var(" + ROTATION_PROP + ", 0deg)")); + stripGsapTranslateFromTransform(el); + } + } + reapplyMotionTimeline(); + }; + + const runtimeWindow = window as Window & { + __hf?: Record; + __player?: Record; + }; + + const isWrapped = (fn: (time: number) => unknown): boolean => + Boolean((fn as unknown as Record)[WRAPPED_PROP]); + + const markWrapped = (fn: (time: number) => unknown): void => { + try { + Object.defineProperty(fn, WRAPPED_PROP, { + configurable: false, + enumerable: false, + value: true, + }); + } catch { + try { + (fn as unknown as Record)[WRAPPED_PROP] = true; + } catch { + /* ignore */ + } + } + }; + + const wrapFn = (get: () => unknown, set: (fn: (time: number) => unknown) => void): boolean => { + const fn = get(); + if (typeof fn !== "function") return false; + const seek = fn as (time: number) => unknown; + if (isWrapped(seek)) { + reapplyAll(); + return true; + } + const wrapped = function (this: unknown, time: number): unknown { + lastSeekTime = typeof time === "number" && Number.isFinite(time) ? Math.max(0, time) : 0; + const result = seek.call(this, time); + reapplyAll(); + return result; + }; + markWrapped(wrapped); + set(wrapped); + reapplyAll(); + return true; + }; + + const wrapSeekFunctions = (): boolean => { + const a = wrapFn( + () => runtimeWindow.__hf?.["seek"], + (fn) => { + if (runtimeWindow.__hf) runtimeWindow.__hf["seek"] = fn; + }, + ); + const b = wrapFn( + () => runtimeWindow.__player?.["renderSeek"], + (fn) => { + if (runtimeWindow.__player) runtimeWindow.__player["renderSeek"] = fn; + }, + ); + return a || b; + }; + + const installSeekTrap = ( + obj: Record | undefined, + key: string, + getter: () => unknown, + setter: (fn: (time: number) => unknown) => void, + ): void => { + if (!obj) return; + try { + let current = obj[key]; + Object.defineProperty(obj, key, { + configurable: true, + enumerable: true, + get() { + return current; + }, + set(value: unknown) { + current = value; + if (typeof value === "function" && !isWrapped(value as (time: number) => unknown)) { + wrapFn(getter, setter); + } + }, + }); + } catch { + /* non-configurable — fall back to polling */ + } + }; + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", () => reapplyAll(), { once: true }); + } else { + reapplyAll(); + } + + wrapSeekFunctions(); + installSeekTrap( + runtimeWindow.__hf, + "seek", + () => runtimeWindow.__hf?.["seek"], + (fn) => { + if (runtimeWindow.__hf) runtimeWindow.__hf["seek"] = fn; + }, + ); + installSeekTrap( + runtimeWindow.__player as Record | undefined, + "renderSeek", + () => runtimeWindow.__player?.["renderSeek"], + (fn) => { + if (runtimeWindow.__player) runtimeWindow.__player["renderSeek"] = fn; + }, + ); + let remaining = 120; + const interval = setInterval(() => { + wrapSeekFunctions(); + remaining -= 1; + if (remaining <= 0) clearInterval(interval); + }, 50); +} + +function studioManualEditsRenderRuntime( + manifestContent: string, + activeCompositionPath: string | null, +): void { + const OFFSET_X_PROP = "--hf-studio-offset-x"; + const OFFSET_Y_PROP = "--hf-studio-offset-y"; + const WIDTH_PROP = "--hf-studio-width"; + const HEIGHT_PROP = "--hf-studio-height"; + const ROTATION_PROP = "--hf-studio-rotation"; + const PATH_OFFSET_ATTR = "data-hf-studio-path-offset"; + const BOX_SIZE_ATTR = "data-hf-studio-box-size"; + const ROTATION_ATTR = "data-hf-studio-rotation"; + const ORIGINAL_TRANSLATE_ATTR = "data-hf-studio-original-translate"; + const ORIGINAL_ROTATE_ATTR = "data-hf-studio-original-rotate"; + const WRAPPED_SEEK_PROP = "__hfStudioManualEditsWrapped"; + const ROTATION_TRANSFORM_ORIGIN = "center center"; + + const finiteNumber = (value: unknown): number | null => + typeof value === "number" && Number.isFinite(value) ? value : null; + + const objectRecord = (value: unknown): Record | null => + value && typeof value === "object" ? (value as Record) : null; + + const runtimeWindow = window as Window & { + __hf?: { seek?: (time: number) => unknown }; + __hfStudioManualEditsApply?: () => number; + __player?: { renderSeek?: (time: number) => unknown }; + }; + + const parsedManifest = (() => { + try { + return objectRecord(JSON.parse(manifestContent)); + } catch { + return null; + } + })(); + const manifestEdits = Array.isArray(parsedManifest?.edits) ? parsedManifest.edits : []; + if (manifestEdits.length === 0) return; + + const sourceFileForElement = (element: HTMLElement): string => { + let current: HTMLElement | null = element; + while (current) { + const sourceFile = + current.getAttribute("data-composition-file") ?? + current.getAttribute("data-composition-src"); + if (sourceFile) return sourceFile; + current = current.parentElement; + } + return activeCompositionPath ?? "index.html"; + }; + + const elementMatchesSourceFile = (element: HTMLElement, sourceFile: string): boolean => + sourceFileForElement(element) === sourceFile; + + const styleUsesStudioOffset = (value: string): boolean => + value.includes(OFFSET_X_PROP) || value.includes(OFFSET_Y_PROP); + + const styleUsesStudioRotation = (value: string): boolean => value.includes(ROTATION_PROP); + + const splitTopLevelWhitespace = (value: string): string[] => { + const parts: string[] = []; + let depth = 0; + let current = ""; + for (const char of value.trim()) { + if (char === "(") depth += 1; + if (char === ")") depth = Math.max(0, depth - 1); + if (/\s/.test(char) && depth === 0) { + if (current) parts.push(current); + current = ""; + } else { + current += char; + } + } + if (current) parts.push(current); + return parts; + }; + + const composeTranslate = (element: HTMLElement, x: string, y: string): string => { + const original = element.getAttribute(ORIGINAL_TRANSLATE_ATTR)?.trim(); + if (!original || original === "none") return `${x} ${y}`; + + const parts = splitTopLevelWhitespace(original); + if (parts.length === 1) return `calc(${parts[0]} + ${x}) ${y}`; + if (parts.length === 2) return `calc(${parts[0]} + ${x}) calc(${parts[1]} + ${y})`; + if (parts.length === 3) { + return `calc(${parts[0]} + ${x}) calc(${parts[1]} + ${y}) ${parts[2]}`; + } + return `${x} ${y}`; + }; + + const readStyleOrComputed = (element: HTMLElement, property: string): string => { + try { + return ( + element.style.getPropertyValue(property) || + getComputedStyle(element).getPropertyValue(property) + ); + } catch { + return element.style.getPropertyValue(property); + } + }; + + const readTransformLonghandBase = ( + element: HTMLElement, + property: "translate" | "rotate", + ): string => { + const value = readStyleOrComputed(element, property).trim(); + return value === "none" ? "" : value; + }; + + const preparePathOffsetBase = (element: HTMLElement): void => { + const currentTranslate = readTransformLonghandBase(element, "translate"); + const hasMarker = element.hasAttribute(PATH_OFFSET_ATTR); + const wasResetByAnimation = !styleUsesStudioOffset(currentTranslate); + if (!hasMarker) { + element.setAttribute(ORIGINAL_TRANSLATE_ATTR, wasResetByAnimation ? currentTranslate : ""); + } else if (wasResetByAnimation) { + element.setAttribute(ORIGINAL_TRANSLATE_ATTR, currentTranslate); + } + }; + + const prepareRotationBase = (element: HTMLElement): void => { + const currentRotate = readTransformLonghandBase(element, "rotate"); + const hasMarker = element.hasAttribute(ROTATION_ATTR); + const wasResetByAnimation = !styleUsesStudioRotation(currentRotate); + if (!hasMarker) { + element.setAttribute(ORIGINAL_ROTATE_ATTR, wasResetByAnimation ? currentRotate : ""); + } else if (wasResetByAnimation) { + element.setAttribute(ORIGINAL_ROTATE_ATTR, currentRotate); + } + }; + + const querySelectorCandidates = (selector: string): HTMLElement[] => { + const isCandidate = (element: Element): element is HTMLElement => + element instanceof HTMLElement; + + const className = selector.match(/^\.([A-Za-z0-9_-]+)$/)?.[1]; + if (className) { + return Array.from(document.getElementsByTagName("*")).filter( + (element): element is HTMLElement => + isCandidate(element) && element.classList.contains(className), + ); + } + + if (/^[A-Za-z][A-Za-z0-9-]*$/.test(selector)) { + return Array.from(document.getElementsByTagName(selector)).filter(isCandidate); + } + + return Array.from(document.querySelectorAll(selector)).filter(isCandidate); + }; + + const resolveTarget = (edit: Record): HTMLElement | null => { + const targetRecord = objectRecord(edit.target); + if (!targetRecord) return null; + + const sourceFile = typeof targetRecord.sourceFile === "string" ? targetRecord.sourceFile : ""; + if (!sourceFile) return null; + + const id = typeof targetRecord.id === "string" ? targetRecord.id : ""; + if (id) { + const byId = document.getElementById(id); + if (byId instanceof HTMLElement && elementMatchesSourceFile(byId, sourceFile)) return byId; + + const matchesById = [ + document.documentElement, + ...Array.from(document.getElementsByTagName("*")), + ].filter( + (element): element is HTMLElement => + element instanceof HTMLElement && + element.id === id && + elementMatchesSourceFile(element, sourceFile), + ); + if (matchesById[0]) return matchesById[0]; + } + + const selector = typeof targetRecord.selector === "string" ? targetRecord.selector : ""; + if (!selector) return null; + + try { + const matches = querySelectorCandidates(selector).filter((element) => + elementMatchesSourceFile(element, sourceFile), + ); + const selectorIndex = finiteNumber(targetRecord.selectorIndex) ?? 0; + return matches[Math.max(0, Math.floor(selectorIndex))] ?? null; + } catch { + return null; + } + }; + + const roundRotationAngle = (angle: number): number => Math.round(angle * 10) / 10; + + const isSimpleRotateAngle = (value: string): boolean => + /^-?(?:\d+(?:\.\d+)?|\.\d+)(?:deg|rad|turn|grad)$/.test(value.trim()); + + const composeRotation = (element: HTMLElement, rotationValue: string): string => { + const original = element.getAttribute(ORIGINAL_ROTATE_ATTR)?.trim(); + if (!original || original === "none" || !isSimpleRotateAngle(original)) { + return rotationValue; + } + return `calc(${original} + ${rotationValue})`; + }; + + const applyPathOffset = (element: HTMLElement, edit: Record): void => { + const x = finiteNumber(edit.x); + const y = finiteNumber(edit.y); + if (x == null || y == null) return; + preparePathOffsetBase(element); + element.setAttribute(PATH_OFFSET_ATTR, "true"); + element.style.setProperty(OFFSET_X_PROP, `${Math.round(x)}px`); + element.style.setProperty(OFFSET_Y_PROP, `${Math.round(y)}px`); + element.style.setProperty( + "translate", + composeTranslate(element, `var(${OFFSET_X_PROP}, 0px)`, `var(${OFFSET_Y_PROP}, 0px)`), + ); + }; + + const readParentFlexBasisPixels = ( + element: HTMLElement, + size: { width: number; height: number }, + ): number | null => { + const parent = element.parentElement; + if (!parent) return null; + const styles = getComputedStyle(parent); + if (styles.display !== "flex" && styles.display !== "inline-flex") return null; + return Math.round( + Math.max(1, styles.flexDirection.startsWith("column") ? size.height : size.width), + ); + }; + + const applyBoxSize = (element: HTMLElement, edit: Record): void => { + const width = finiteNumber(edit.width); + const height = finiteNumber(edit.height); + if (width == null || height == null || width <= 0 || height <= 0) return; + + const rounded = { + width: Math.round(Math.max(1, width)), + height: Math.round(Math.max(1, height)), + }; + element.setAttribute(BOX_SIZE_ATTR, "true"); + element.style.setProperty(WIDTH_PROP, `${rounded.width}px`); + element.style.setProperty(HEIGHT_PROP, `${rounded.height}px`); + element.style.setProperty("box-sizing", "border-box"); + element.style.setProperty("width", `${rounded.width}px`); + element.style.setProperty("height", `${rounded.height}px`); + element.style.setProperty("min-width", "0px"); + element.style.setProperty("min-height", "0px"); + element.style.setProperty("max-width", "none"); + element.style.setProperty("max-height", "none"); + + const flexBasis = readParentFlexBasisPixels(element, rounded); + if (flexBasis != null) { + element.style.setProperty("flex-basis", `${flexBasis}px`); + element.style.setProperty("flex-grow", "0"); + element.style.setProperty("flex-shrink", "0"); + } + if (getComputedStyle(element).display === "inline") { + element.style.setProperty("display", "inline-block"); + } + }; + + const applyRotation = (element: HTMLElement, edit: Record): void => { + const angle = finiteNumber(edit.angle); + if (angle == null) return; + prepareRotationBase(element); + element.setAttribute(ROTATION_ATTR, "true"); + element.style.setProperty(ROTATION_PROP, `${roundRotationAngle(angle)}deg`); + element.style.setProperty("transform-origin", ROTATION_TRANSFORM_ORIGIN); + element.style.setProperty("rotate", composeRotation(element, `var(${ROTATION_PROP}, 0deg)`)); + }; + + const applyManifest = (): number => { + let applied = 0; + for (const edit of manifestEdits) { + const editRecord = objectRecord(edit); + if (!editRecord) continue; + const element = resolveTarget(editRecord); + if (!element) continue; + if (editRecord.kind === "path-offset") applyPathOffset(element, editRecord); + if (editRecord.kind === "box-size") applyBoxSize(element, editRecord); + if (editRecord.kind === "rotation") applyRotation(element, editRecord); + applied += 1; + } + return applied; + }; + runtimeWindow.__hfStudioManualEditsApply = applyManifest; + + const markWrapped = (fn: (time: number) => unknown): void => { + try { + Object.defineProperty(fn, WRAPPED_SEEK_PROP, { + configurable: false, + enumerable: false, + value: true, + }); + } catch { + try { + (fn as unknown as Record)[WRAPPED_SEEK_PROP] = true; + } catch { + // Ignore non-extensible functions. + } + } + }; + + const isWrapped = (fn: (time: number) => unknown): boolean => + Boolean((fn as unknown as Record)[WRAPPED_SEEK_PROP]); + + const wrapFunction = ( + get: () => ((time: number) => unknown) | undefined, + set: (fn: (time: number) => unknown) => void, + ): boolean => { + const fn = get(); + if (!fn) return false; + const seek = fn as (time: number) => unknown; + if (isWrapped(seek)) { + applyManifest(); + return true; + } + + const wrappedSeek = function (this: unknown, time: number): unknown { + const result = seek.call(this, time); + applyManifest(); + return result; + }; + markWrapped(wrappedSeek); + set(wrappedSeek); + applyManifest(); + return true; + }; + + const wrapSeekFunctions = (): boolean => { + const wrappedHfSeek = wrapFunction( + () => runtimeWindow.__hf?.seek, + (fn) => { + if (runtimeWindow.__hf) runtimeWindow.__hf.seek = fn; + }, + ); + const wrappedPlayerRenderSeek = wrapFunction( + () => runtimeWindow.__player?.renderSeek, + (fn) => { + if (runtimeWindow.__player) runtimeWindow.__player.renderSeek = fn; + }, + ); + return wrappedHfSeek || wrappedPlayerRenderSeek; + }; + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", () => applyManifest(), { once: true }); + } else { + applyManifest(); + } + + wrapSeekFunctions(); + let remainingSeekWrapAttempts = 120; + const seekWrapInterval = setInterval(() => { + wrapSeekFunctions(); + remainingSeekWrapAttempts -= 1; + if (remainingSeekWrapAttempts <= 0) clearInterval(seekWrapInterval); + }, 50); +} diff --git a/packages/core/src/studio-api/helpers/mediaValidation.test.ts b/packages/studio-server/src/helpers/mediaValidation.test.ts similarity index 100% rename from packages/core/src/studio-api/helpers/mediaValidation.test.ts rename to packages/studio-server/src/helpers/mediaValidation.test.ts diff --git a/packages/core/src/studio-api/helpers/mediaValidation.ts b/packages/studio-server/src/helpers/mediaValidation.ts similarity index 100% rename from packages/core/src/studio-api/helpers/mediaValidation.ts rename to packages/studio-server/src/helpers/mediaValidation.ts diff --git a/packages/core/src/studio-api/helpers/mime.ts b/packages/studio-server/src/helpers/mime.ts similarity index 100% rename from packages/core/src/studio-api/helpers/mime.ts rename to packages/studio-server/src/helpers/mime.ts diff --git a/packages/core/src/studio-api/helpers/previewAdapter.test.ts b/packages/studio-server/src/helpers/previewAdapter.test.ts similarity index 100% rename from packages/core/src/studio-api/helpers/previewAdapter.test.ts rename to packages/studio-server/src/helpers/previewAdapter.test.ts diff --git a/packages/core/src/studio-api/helpers/previewAdapter.ts b/packages/studio-server/src/helpers/previewAdapter.ts similarity index 100% rename from packages/core/src/studio-api/helpers/previewAdapter.ts rename to packages/studio-server/src/helpers/previewAdapter.ts diff --git a/packages/core/src/studio-api/helpers/projectSignature.ts b/packages/studio-server/src/helpers/projectSignature.ts similarity index 100% rename from packages/core/src/studio-api/helpers/projectSignature.ts rename to packages/studio-server/src/helpers/projectSignature.ts diff --git a/packages/core/src/studio-api/helpers/safePath.test.ts b/packages/studio-server/src/helpers/safePath.test.ts similarity index 100% rename from packages/core/src/studio-api/helpers/safePath.test.ts rename to packages/studio-server/src/helpers/safePath.test.ts diff --git a/packages/core/src/studio-api/helpers/safePath.ts b/packages/studio-server/src/helpers/safePath.ts similarity index 95% rename from packages/core/src/studio-api/helpers/safePath.ts rename to packages/studio-server/src/helpers/safePath.ts index de1d909844..688eb2386e 100644 --- a/packages/core/src/studio-api/helpers/safePath.ts +++ b/packages/studio-server/src/helpers/safePath.ts @@ -4,7 +4,7 @@ import { readdirSync } from "node:fs"; // `isSafePath` lives at the package root so non-studio-api layers (compiler, // CLI, engine) can share it without a backwards dependency on studio-api. // Re-exported here for back-compat with existing `../helpers/safePath.js` imports. -export { isSafePath, resolveWithinProject } from "../../safePath.js"; +export { isSafePath, resolveWithinProject } from "@hyperframes/core"; const IGNORE_DIRS = new Set([".thumbnails", "node_modules", ".git"]); diff --git a/packages/studio-server/src/helpers/screenshotClip.ts b/packages/studio-server/src/helpers/screenshotClip.ts new file mode 100644 index 0000000000..a1db59033e --- /dev/null +++ b/packages/studio-server/src/helpers/screenshotClip.ts @@ -0,0 +1,31 @@ +export interface ScreenshotClip { + x: number; + y: number; + width: number; + height: number; +} + +export function getElementScreenshotClip( + selector: string, + selectorIndex?: number, +): ScreenshotClip | undefined { + const matches = Array.from(document.querySelectorAll(selector)).filter( + (el): el is HTMLElement => el instanceof HTMLElement, + ); + const safeIndex = Math.max(0, Math.min(matches.length - 1, Math.floor(selectorIndex ?? 0))); + const el = matches[safeIndex] ?? null; + if (!(el instanceof HTMLElement)) return undefined; + const rect = el.getBoundingClientRect(); + if (rect.width < 4 || rect.height < 4) return undefined; + const pad = 8; + const x = Math.max(0, rect.left - pad); + const y = Math.max(0, rect.top - pad); + const maxWidth = window.innerWidth - x; + const maxHeight = window.innerHeight - y; + return { + x, + y, + width: Math.max(1, Math.min(rect.width + pad * 2, maxWidth)), + height: Math.max(1, Math.min(rect.height + pad * 2, maxHeight)), + }; +} diff --git a/packages/core/src/studio-api/helpers/sourceMutation.test.ts b/packages/studio-server/src/helpers/sourceMutation.test.ts similarity index 100% rename from packages/core/src/studio-api/helpers/sourceMutation.test.ts rename to packages/studio-server/src/helpers/sourceMutation.test.ts diff --git a/packages/core/src/studio-api/helpers/sourceMutation.ts b/packages/studio-server/src/helpers/sourceMutation.ts similarity index 99% rename from packages/core/src/studio-api/helpers/sourceMutation.ts rename to packages/studio-server/src/helpers/sourceMutation.ts index 727cd326a2..14951aaf33 100644 --- a/packages/core/src/studio-api/helpers/sourceMutation.ts +++ b/packages/studio-server/src/helpers/sourceMutation.ts @@ -1,7 +1,7 @@ import { parseHTML } from "linkedom"; import postcss from "postcss"; import selectorParser from "postcss-selector-parser"; -import { isAllowedHtmlAttribute, isSafeAttributeValue } from "../../utils/htmlAttrSafety"; +import { isAllowedHtmlAttribute, isSafeAttributeValue } from "@hyperframes/core/html-attr-safety"; export interface SourceMutationTarget { id?: string | null; diff --git a/packages/core/src/studio-api/helpers/studioMotionRenderScript.test.ts b/packages/studio-server/src/helpers/studioMotionRenderScript.test.ts similarity index 100% rename from packages/core/src/studio-api/helpers/studioMotionRenderScript.test.ts rename to packages/studio-server/src/helpers/studioMotionRenderScript.test.ts diff --git a/packages/studio-server/src/helpers/studioMotionRenderScript.ts b/packages/studio-server/src/helpers/studioMotionRenderScript.ts new file mode 100644 index 0000000000..87d2b815ef --- /dev/null +++ b/packages/studio-server/src/helpers/studioMotionRenderScript.ts @@ -0,0 +1,260 @@ +export interface StudioMotionRenderScriptOptions { + activeCompositionPath?: string | null; +} + +export const STUDIO_MOTION_PATH = ".hyperframes/studio-motion.json"; + +function hasStudioMotionEntries(manifestContent: string): boolean { + try { + const parsed = JSON.parse(manifestContent) as { motions?: unknown }; + return Array.isArray(parsed.motions) && parsed.motions.length > 0; + } catch { + return false; + } +} + +/** + * Builds the render-time Studio motion runtime script, or null when no owned motion exists. + */ +export function createStudioMotionRenderBodyScript( + manifestContent: string, + options: StudioMotionRenderScriptOptions = {}, +): string | null { + if (!manifestContent.trim() || !hasStudioMotionEntries(manifestContent)) return null; + return `(${studioMotionRenderRuntime.toString()})(${JSON.stringify(manifestContent)}, ${JSON.stringify(options.activeCompositionPath ?? null)});`; +} + +function studioMotionRenderRuntime( + manifestContent: string, + activeCompositionPath: string | null, +): void { + const STUDIO_MOTION_TIMELINE_ID = "studio-motion"; + const STUDIO_MOTION_ATTR = "data-hf-studio-motion"; + const ORIGINAL_TRANSFORM_ATTR = "data-hf-studio-motion-original-transform"; + const ORIGINAL_OPACITY_ATTR = "data-hf-studio-motion-original-opacity"; + const ORIGINAL_VISIBILITY_ATTR = "data-hf-studio-motion-original-visibility"; + + const objectRecord = (value: unknown): Record | null => + value && typeof value === "object" ? (value as Record) : null; + + const finiteNumber = (value: unknown): number | null => + typeof value === "number" && Number.isFinite(value) ? value : null; + + const runtimeWindow = window as Window & { + gsap?: { + timeline?: (vars?: Record) => { + fromTo?: ( + target: HTMLElement, + from: Record, + to: Record, + at: number, + ) => unknown; + totalTime?: (time: number, suppressEvents?: boolean) => unknown; + time?: (time: number) => unknown; + pause?: () => unknown; + kill?: () => unknown; + }; + set?: (target: HTMLElement, vars: Record) => unknown; + registerPlugin?: (...plugins: unknown[]) => unknown; + }; + CustomEase?: { create?: (id: string, data: string) => unknown }; + __player?: { getTime?: () => number }; + __timeline?: { time?: () => number }; + __timelines?: Record< + string, + | { + kill?: () => unknown; + } + | undefined + >; + __hfStudioMotionApply?: () => number; + }; + + const parseMotionValues = (value: unknown): Record | null => { + const record = objectRecord(value); + if (!record) return null; + const parsed: Record = {}; + for (const key of ["x", "y", "scale", "rotation", "opacity", "autoAlpha"]) { + const next = finiteNumber(record[key]); + if (next != null) parsed[key] = next; + } + return Object.keys(parsed).length > 0 ? parsed : null; + }; + + const parseCustomEase = (value: unknown): { id: string; data: string } | null => { + const record = objectRecord(value); + if (!record) return null; + const id = typeof record.id === "string" ? record.id.trim() : ""; + const data = typeof record.data === "string" ? record.data.trim() : ""; + if (!id || !data) return null; + return { id, data }; + }; + + const parsedManifest = (() => { + try { + return objectRecord(JSON.parse(manifestContent)); + } catch { + return null; + } + })(); + const manifestMotions = Array.isArray(parsedManifest?.motions) ? parsedManifest.motions : []; + + const sourceFileForElement = (element: HTMLElement): string => { + let current: HTMLElement | null = element; + while (current) { + const sourceFile = + current.getAttribute("data-composition-file") ?? + current.getAttribute("data-composition-src"); + if (sourceFile) return sourceFile; + current = current.parentElement; + } + return activeCompositionPath ?? "index.html"; + }; + + const elementMatchesSourceFile = (element: HTMLElement, sourceFile: string): boolean => + sourceFileForElement(element) === sourceFile; + + const isHTMLElement = (element: Element | null): element is HTMLElement => + element instanceof HTMLElement; + + const querySelectorCandidates = (selector: string): HTMLElement[] => { + const className = selector.match(/^\.([A-Za-z0-9_-]+)$/)?.[1]; + if (className) { + return Array.from(document.getElementsByTagName("*")).filter( + (element): element is HTMLElement => + isHTMLElement(element) && element.classList.contains(className), + ); + } + if (/^[A-Za-z][A-Za-z0-9-]*$/.test(selector)) { + return Array.from(document.getElementsByTagName(selector)).filter(isHTMLElement); + } + return Array.from(document.querySelectorAll(selector)).filter(isHTMLElement); + }; + + const resolveTarget = (targetRecord: Record): HTMLElement | null => { + const sourceFile = typeof targetRecord.sourceFile === "string" ? targetRecord.sourceFile : ""; + if (!sourceFile) return null; + const id = typeof targetRecord.id === "string" ? targetRecord.id : ""; + if (id) { + const byId = document.getElementById(id); + if (isHTMLElement(byId) && elementMatchesSourceFile(byId, sourceFile)) return byId; + } + const selector = typeof targetRecord.selector === "string" ? targetRecord.selector : ""; + if (!selector) return null; + try { + const selectorIndex = Math.max(0, Math.floor(finiteNumber(targetRecord.selectorIndex) ?? 0)); + return ( + querySelectorCandidates(selector).filter((element) => + elementMatchesSourceFile(element, sourceFile), + )[selectorIndex] ?? null + ); + } catch { + return null; + } + }; + + const restoreElement = (element: HTMLElement): void => { + runtimeWindow.gsap?.set?.(element, { clearProps: "transform,opacity,visibility" }); + element.style.transform = element.getAttribute(ORIGINAL_TRANSFORM_ATTR) ?? ""; + element.style.opacity = element.getAttribute(ORIGINAL_OPACITY_ATTR) ?? ""; + element.style.visibility = element.getAttribute(ORIGINAL_VISIBILITY_ATTR) ?? ""; + element.removeAttribute(STUDIO_MOTION_ATTR); + element.removeAttribute(ORIGINAL_TRANSFORM_ATTR); + element.removeAttribute(ORIGINAL_OPACITY_ATTR); + element.removeAttribute(ORIGINAL_VISIBILITY_ATTR); + }; + + const restoreStudioMotionElements = (): void => { + for (const element of Array.from(document.querySelectorAll(`[${STUDIO_MOTION_ATTR}]`))) { + if (isHTMLElement(element)) restoreElement(element); + } + }; + + const readCurrentTime = (): number => { + try { + const playerTime = runtimeWindow.__player?.getTime?.(); + if (typeof playerTime === "number" && Number.isFinite(playerTime)) { + return Math.max(0, playerTime); + } + } catch { + // fall through + } + try { + const timelineTime = runtimeWindow.__timeline?.time?.(); + if (typeof timelineTime === "number" && Number.isFinite(timelineTime)) { + return Math.max(0, timelineTime); + } + } catch { + // fall through + } + return 0; + }; + + const resolveEase = (motion: Record): string => { + const fallback = + typeof motion.ease === "string" && motion.ease.trim() ? motion.ease.trim() : "none"; + const customEase = parseCustomEase(motion.customEase); + const customEasePlugin = runtimeWindow.CustomEase; + if (!customEase || typeof customEasePlugin?.create !== "function") return fallback; + try { + runtimeWindow.gsap?.registerPlugin?.(customEasePlugin); + customEasePlugin.create(customEase.id, customEase.data); + return customEase.id; + } catch { + return fallback; + } + }; + + const applyManifest = (): number => { + runtimeWindow.__timelines = runtimeWindow.__timelines ?? {}; + runtimeWindow.__timelines[STUDIO_MOTION_TIMELINE_ID]?.kill?.(); + delete runtimeWindow.__timelines[STUDIO_MOTION_TIMELINE_ID]; + restoreStudioMotionElements(); + const gsap = runtimeWindow.gsap; + if (!gsap?.timeline || manifestMotions.length === 0) return 0; + + const timeline = gsap.timeline({ paused: true, defaults: { overwrite: "auto" } }); + let applied = 0; + for (const motionValue of manifestMotions) { + const motion = objectRecord(motionValue); + if (!motion || motion.kind !== "gsap-motion") continue; + const targetRecord = objectRecord(motion.target); + if (!targetRecord) continue; + const target = resolveTarget(targetRecord); + if (!target || typeof timeline.fromTo !== "function") continue; + const start = finiteNumber(motion.start); + const duration = finiteNumber(motion.duration); + if (start == null || duration == null || start < 0 || duration <= 0) continue; + const from = parseMotionValues(motion.from); + const to = parseMotionValues(motion.to); + if (!from || !to) continue; + if (!target.hasAttribute(STUDIO_MOTION_ATTR)) { + target.setAttribute(ORIGINAL_TRANSFORM_ATTR, target.style.transform); + target.setAttribute(ORIGINAL_OPACITY_ATTR, target.style.opacity); + target.setAttribute(ORIGINAL_VISIBILITY_ATTR, target.style.visibility); + } + target.setAttribute(STUDIO_MOTION_ATTR, "true"); + timeline.fromTo( + target, + from, + { ...to, duration, ease: resolveEase(motion), overwrite: "auto", immediateRender: false }, + start, + ); + applied += 1; + } + + if (applied === 0) { + timeline.kill?.(); + return 0; + } + runtimeWindow.__timelines[STUDIO_MOTION_TIMELINE_ID] = timeline; + timeline.pause?.(); + const currentTime = readCurrentTime(); + if (typeof timeline.totalTime === "function") timeline.totalTime(currentTime, false); + else timeline.time?.(currentTime); + return applied; + }; + + runtimeWindow.__hfStudioMotionApply = applyManifest; + applyManifest(); +} diff --git a/packages/core/src/studio-api/helpers/subComposition.test.ts b/packages/studio-server/src/helpers/subComposition.test.ts similarity index 100% rename from packages/core/src/studio-api/helpers/subComposition.test.ts rename to packages/studio-server/src/helpers/subComposition.test.ts diff --git a/packages/core/src/studio-api/helpers/subComposition.ts b/packages/studio-server/src/helpers/subComposition.ts similarity index 99% rename from packages/core/src/studio-api/helpers/subComposition.ts rename to packages/studio-server/src/helpers/subComposition.ts index 5695de7f3c..eb5ca69031 100644 --- a/packages/core/src/studio-api/helpers/subComposition.ts +++ b/packages/studio-server/src/helpers/subComposition.ts @@ -5,8 +5,8 @@ import { rewriteAssetPaths, rewriteCssAssetUrls, rewriteInlineStyleAssetUrls, -} from "../../compiler/rewriteSubCompPaths.js"; -import { stripEmbeddedRuntimeScripts } from "../../compiler/htmlDocument.js"; +} from "@hyperframes/core"; +import { stripEmbeddedRuntimeScripts } from "@hyperframes/core/compiler"; /** * Detect whether `html` is a full document (has ``, ``, or diff --git a/packages/core/src/studio-api/helpers/waveform.ts b/packages/studio-server/src/helpers/waveform.ts similarity index 100% rename from packages/core/src/studio-api/helpers/waveform.ts rename to packages/studio-server/src/helpers/waveform.ts diff --git a/packages/studio-server/src/index.ts b/packages/studio-server/src/index.ts new file mode 100644 index 0000000000..bbc77f69d6 --- /dev/null +++ b/packages/studio-server/src/index.ts @@ -0,0 +1,18 @@ +export { createStudioApi } from "./createStudioApi.js"; +export { createProjectSignature } from "./helpers/projectSignature.js"; +export type { StudioApiAdapter, ResolvedProject, RenderJobState, LintResult } from "./types.js"; +export { isSafePath, walkDir } from "./helpers/safePath.js"; +export { getMimeType, MIME_TYPES } from "./helpers/mime.js"; +export { buildSubCompositionHtml } from "./helpers/subComposition.js"; +export { getElementScreenshotClip, type ScreenshotClip } from "./helpers/screenshotClip.js"; +export { + STUDIO_MANUAL_EDITS_PATH, + createStudioManualEditsRenderBodyScript, + createStudioPositionSeekReapplyScript, + type StudioManualEditsRenderScriptOptions, +} from "./helpers/manualEditsRenderScript.js"; +export { + STUDIO_MOTION_PATH, + createStudioMotionRenderBodyScript, + type StudioMotionRenderScriptOptions, +} from "./helpers/studioMotionRenderScript.js"; diff --git a/packages/core/src/studio-api/routes/files.test.ts b/packages/studio-server/src/routes/files.test.ts similarity index 100% rename from packages/core/src/studio-api/routes/files.test.ts rename to packages/studio-server/src/routes/files.test.ts diff --git a/packages/core/src/studio-api/routes/files.ts b/packages/studio-server/src/routes/files.ts similarity index 99% rename from packages/core/src/studio-api/routes/files.ts rename to packages/studio-server/src/routes/files.ts index 08d4fdd202..c628d49c6e 100644 --- a/packages/core/src/studio-api/routes/files.ts +++ b/packages/studio-server/src/routes/files.ts @@ -27,10 +27,10 @@ import { findUnsafeMutationValues, type UnsafeMutationValue, } from "../helpers/finiteMutation.js"; -import type { GsapAnimation } from "../../parsers/gsapSerialize.js"; -import { classifyPropertyGroup } from "../../parsers/gsapConstants.js"; -import { parseGsapScriptAcorn } from "../../parsers/gsapParserAcorn.js"; -import { unrollComputedTimeline } from "../../parsers/gsapUnroll.js"; +import type { GsapAnimation } from "@hyperframes/parsers"; +import { classifyPropertyGroup } from "@hyperframes/parsers/gsap-constants"; +import { parseGsapScriptAcorn } from "@hyperframes/parsers/gsap-parser-acorn"; +import { unrollComputedTimeline } from "@hyperframes/parsers"; import { updateAnimationInScript, addAnimationToScript, @@ -50,7 +50,7 @@ import { splitIntoPropertyGroupsFromScript, shiftPositionsInScript, scalePositionsInScript, -} from "../../parsers/gsapWriterAcorn.js"; +} from "@hyperframes/parsers/gsap-writer-acorn"; import { removeElementFromHtml, patchElementInHtml, @@ -82,7 +82,7 @@ function isAcornGsapWriterEnabled(): boolean { * for the recast write path (the default when STUDIO_SDK_CUTOVER_ENABLED is off). */ async function loadGsapParser() { - return import("../../parsers/gsapParser.js"); + return import("@hyperframes/parsers/gsap-parser-recast"); } // ── Shared helpers ────────────────────────────────────────────────────────── @@ -1708,7 +1708,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { let block = extractGsapScriptBlock(html); if (!block && (body.type === "add" || body.type === "add-with-keyframes")) { const compId = html.match(/data-composition-id="([^"]+)"/)?.[1] ?? "main"; - const { GSAP_CDN } = await import("../../templates/constants.js"); + const { GSAP_CDN } = await import("@hyperframes/core"); const gsapCdn = ``; const bootstrap = [ gsapCdn, diff --git a/packages/core/src/studio-api/routes/fonts.ts b/packages/studio-server/src/routes/fonts.ts similarity index 99% rename from packages/core/src/studio-api/routes/fonts.ts rename to packages/studio-server/src/routes/fonts.ts index ac92aa8ddb..9698ac324a 100644 --- a/packages/core/src/studio-api/routes/fonts.ts +++ b/packages/studio-server/src/routes/fonts.ts @@ -6,7 +6,7 @@ import { getSystemProfilerFamilies, locateSystemFont, SYSTEM_FONT_SIZE_LIMIT, -} from "../../fonts/systemFontLocator"; +} from "@hyperframes/core/fonts/system-locator"; const MAX_FONT_RESULTS = 2000; const GOOGLE_FONTS_METADATA_URL = "https://fonts.google.com/metadata/fonts"; diff --git a/packages/core/src/studio-api/routes/lint.test.ts b/packages/studio-server/src/routes/lint.test.ts similarity index 100% rename from packages/core/src/studio-api/routes/lint.test.ts rename to packages/studio-server/src/routes/lint.test.ts diff --git a/packages/core/src/studio-api/routes/lint.ts b/packages/studio-server/src/routes/lint.ts similarity index 100% rename from packages/core/src/studio-api/routes/lint.ts rename to packages/studio-server/src/routes/lint.ts diff --git a/packages/core/src/studio-api/routes/preview.test.ts b/packages/studio-server/src/routes/preview.test.ts similarity index 100% rename from packages/core/src/studio-api/routes/preview.test.ts rename to packages/studio-server/src/routes/preview.test.ts diff --git a/packages/core/src/studio-api/routes/preview.ts b/packages/studio-server/src/routes/preview.ts similarity index 99% rename from packages/core/src/studio-api/routes/preview.ts rename to packages/studio-server/src/routes/preview.ts index d561808a80..ab53883d43 100644 --- a/packages/core/src/studio-api/routes/preview.ts +++ b/packages/studio-server/src/routes/preview.ts @@ -1,7 +1,7 @@ import type { Hono } from "hono"; import { existsSync, readFileSync, statSync } from "node:fs"; import { join } from "node:path"; -import { injectScriptsIntoHtml, stripEmbeddedRuntimeScripts } from "../../compiler/htmlDocument.js"; +import { injectScriptsIntoHtml, stripEmbeddedRuntimeScripts } from "@hyperframes/core/compiler"; import type { StudioApiAdapter } from "../types.js"; import { resolveWithinProject } from "../helpers/safePath.js"; import { getMimeType } from "../helpers/mime.js"; @@ -11,7 +11,7 @@ import { createStudioMotionRenderBodyScript, STUDIO_MOTION_PATH, } from "../helpers/studioMotionRenderScript.js"; -import { ensureHfIds } from "../../parsers/hfIds.js"; +import { ensureHfIds } from "@hyperframes/parsers/hf-ids"; import { persistHfIdsIfNeeded } from "../helpers/hfIdPersist.js"; const PROJECT_SIGNATURE_META = "hyperframes-project-signature"; diff --git a/packages/core/src/studio-api/routes/projects.test.ts b/packages/studio-server/src/routes/projects.test.ts similarity index 100% rename from packages/core/src/studio-api/routes/projects.test.ts rename to packages/studio-server/src/routes/projects.test.ts diff --git a/packages/core/src/studio-api/routes/projects.ts b/packages/studio-server/src/routes/projects.ts similarity index 100% rename from packages/core/src/studio-api/routes/projects.ts rename to packages/studio-server/src/routes/projects.ts diff --git a/packages/core/src/studio-api/routes/registry.ts b/packages/studio-server/src/routes/registry.ts similarity index 100% rename from packages/core/src/studio-api/routes/registry.ts rename to packages/studio-server/src/routes/registry.ts diff --git a/packages/core/src/studio-api/routes/render.test.ts b/packages/studio-server/src/routes/render.test.ts similarity index 99% rename from packages/core/src/studio-api/routes/render.test.ts rename to packages/studio-server/src/routes/render.test.ts index fca63003b4..461419b2d3 100644 --- a/packages/core/src/studio-api/routes/render.test.ts +++ b/packages/studio-server/src/routes/render.test.ts @@ -3,7 +3,7 @@ import { Hono } from "hono"; import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { VALID_CANVAS_RESOLUTIONS } from "../../core.types"; +import { VALID_CANVAS_RESOLUTIONS } from "@hyperframes/parsers"; import { registerRenderRoutes } from "./render"; import type { StudioApiAdapter } from "../types"; diff --git a/packages/core/src/studio-api/routes/render.ts b/packages/studio-server/src/routes/render.ts similarity index 98% rename from packages/core/src/studio-api/routes/render.ts rename to packages/studio-server/src/routes/render.ts index 1435148c0d..3cd4bb68e7 100644 --- a/packages/core/src/studio-api/routes/render.ts +++ b/packages/studio-server/src/routes/render.ts @@ -3,7 +3,8 @@ import { streamSSE } from "hono/streaming"; import { existsSync, readFileSync, mkdirSync, unlinkSync, readdirSync, statSync } from "node:fs"; import { join } from "node:path"; import type { StudioApiAdapter, RenderJobState } from "../types.js"; -import { VALID_CANVAS_RESOLUTIONS, parseFps, type CanvasResolution } from "../../core.types.js"; +import { VALID_CANVAS_RESOLUTIONS, type CanvasResolution } from "@hyperframes/parsers"; +import { parseFps } from "@hyperframes/core"; import { resolveWithinProject } from "../helpers/safePath.js"; const VALID_RESOLUTIONS = new Set(VALID_CANVAS_RESOLUTIONS); diff --git a/packages/core/src/studio-api/routes/storyboard.test.ts b/packages/studio-server/src/routes/storyboard.test.ts similarity index 100% rename from packages/core/src/studio-api/routes/storyboard.test.ts rename to packages/studio-server/src/routes/storyboard.test.ts diff --git a/packages/core/src/studio-api/routes/storyboard.ts b/packages/studio-server/src/routes/storyboard.ts similarity index 98% rename from packages/core/src/studio-api/routes/storyboard.ts rename to packages/studio-server/src/routes/storyboard.ts index 1f60c7ac47..874204208b 100644 --- a/packages/core/src/studio-api/routes/storyboard.ts +++ b/packages/studio-server/src/routes/storyboard.ts @@ -7,7 +7,7 @@ import { SCRIPT_FILENAME, STORYBOARD_FILENAME, type StoryboardFrame, -} from "../../storyboard/index.js"; +} from "@hyperframes/core/storyboard"; /** A frame enriched with disk-resolution info the Studio needs to render tiles. */ interface ResolvedStoryboardFrame extends StoryboardFrame { diff --git a/packages/core/src/studio-api/routes/thumbnail.test.ts b/packages/studio-server/src/routes/thumbnail.test.ts similarity index 100% rename from packages/core/src/studio-api/routes/thumbnail.test.ts rename to packages/studio-server/src/routes/thumbnail.test.ts diff --git a/packages/core/src/studio-api/routes/thumbnail.ts b/packages/studio-server/src/routes/thumbnail.ts similarity index 100% rename from packages/core/src/studio-api/routes/thumbnail.ts rename to packages/studio-server/src/routes/thumbnail.ts diff --git a/packages/core/src/studio-api/routes/waveform.ts b/packages/studio-server/src/routes/waveform.ts similarity index 100% rename from packages/core/src/studio-api/routes/waveform.ts rename to packages/studio-server/src/routes/waveform.ts diff --git a/packages/core/src/studio-api/types.ts b/packages/studio-server/src/types.ts similarity index 96% rename from packages/core/src/studio-api/types.ts rename to packages/studio-server/src/types.ts index eb553d2e59..27a6bc5ddb 100644 --- a/packages/core/src/studio-api/types.ts +++ b/packages/studio-server/src/types.ts @@ -1,5 +1,5 @@ -import type { CanvasResolution } from "../core.types.js"; -import type { RegistryItem } from "../registry/types.js"; +import type { CanvasResolution } from "@hyperframes/parsers"; +import type { RegistryItem } from "@hyperframes/core"; /** Resolved info about a single project. */ export interface ResolvedProject { @@ -81,7 +81,7 @@ export interface StudioApiAdapter { * route normalizes both into `Fps` before invoking the adapter, so * adapter implementations only ever see the rational form. */ - fps: import("../core.types.js").Fps; + fps: import("@hyperframes/core").Fps; quality: string; jobId: string; /** diff --git a/packages/studio-server/tsconfig.json b/packages/studio-server/tsconfig.json new file mode 100644 index 0000000000..96f38bce00 --- /dev/null +++ b/packages/studio-server/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noUncheckedIndexedAccess": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} diff --git a/packages/studio-server/tsup.config.ts b/packages/studio-server/tsup.config.ts new file mode 100644 index 0000000000..53aeca203f --- /dev/null +++ b/packages/studio-server/tsup.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: { + index: "src/index.ts", + "helpers/screenshotClip": "src/helpers/screenshotClip.ts", + "helpers/manualEditsRenderScript": "src/helpers/manualEditsRenderScript.ts", + "helpers/studioMotionRenderScript": "src/helpers/studioMotionRenderScript.ts", + "helpers/draftMarkers": "src/helpers/draftMarkers.ts", + "helpers/finiteMutation": "src/helpers/finiteMutation.ts", + }, + format: ["esm"], + outDir: "dist", + target: "node22", + platform: "node", + bundle: true, + splitting: false, + sourcemap: true, + clean: true, + dts: true, +}); diff --git a/packages/studio-server/vitest.config.ts b/packages/studio-server/vitest.config.ts new file mode 100644 index 0000000000..ce36a74267 --- /dev/null +++ b/packages/studio-server/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + environment: "node", + }, +}); From bf26b3dd8473f9686d9839fb4aca5f773198d27a Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 26 Jun 2026 18:56:40 -0400 Subject: [PATCH 07/11] refactor: update consumers to import from new packages directly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update cli, producer, studio, sdk to import from @hyperframes/parsers, @hyperframes/lint, @hyperframes/studio-server - Add new packages to noExternal in cli tsup config for binary bundling - Fix lintProject.test.ts: pass string dir instead of ProjectDir object after lintProject signature change to (projectDir: string) - Add @hyperframes/core/generators subpath to bypass esbuild barrel import in parsers tests (htmlParser.roundtrip + stableIds import test-utils which reached core barrel → hyperframesRuntime.engine → esbuild, breaking TextEncoder invariant in jsdom) - Remove circular lint re-export from core barrel; lint types now live only in @hyperframes/lint (core barrel was core→lint→core cycle) - Exclude runtime binary blobs from @hyperframes/core npm files tarball - Extend health.ignore for lint/project.ts, lint/rules/gsap.ts and files touched only for import-path updates (line-shift fingerprint problem) --- .fallowrc.jsonc | 24 +- bun.lock | 10 +- packages/cli/package.json | 2 + packages/cli/src/commands/lint.ts | 2 +- packages/cli/src/commands/play.ts | 2 +- packages/cli/src/commands/present.ts | 2 +- packages/cli/src/commands/preview.ts | 3 +- packages/cli/src/commands/publish.ts | 7 +- packages/cli/src/commands/render.ts | 2 +- packages/cli/src/server/studioServer.ts | 8 +- packages/cli/src/utils/lintFormat.test.ts | 2 +- packages/cli/src/utils/lintProject.test.ts | 86 ++- packages/cli/src/utils/lintProject.ts | 576 +----------------- packages/cli/src/utils/staticProjectServer.ts | 2 +- packages/cli/tsup.config.ts | 3 + packages/core/package.json | 16 +- packages/core/src/index.ts | 9 +- packages/lint/src/index.ts | 6 +- packages/lint/src/project.ts | 511 ++++++++++++++++ .../parsers/src/htmlParser.roundtrip.test.ts | 2 +- packages/parsers/src/test-utils.ts | 2 +- packages/producer/package.json | 2 + .../producer/src/services/htmlCompiler.ts | 2 +- .../producer/src/services/hyperframeLint.ts | 2 +- packages/sdk/package.json | 1 + packages/sdk/src/document.ts | 2 +- packages/studio/package.json | 2 + .../src/components/editor/domEditingTypes.ts | 2 +- .../editor/gsapAnimationCallbacks.ts | 2 +- .../editor/gsapAnimationHelpers.test.ts | 4 +- .../components/editor/gsapAnimationHelpers.ts | 2 +- .../src/components/editor/manualEditsTypes.ts | 2 +- .../editor/motionPathCommit.test.ts | 2 +- .../components/editor/motionPathSelection.ts | 2 +- .../components/editor/propertyPanelHelpers.ts | 8 +- .../studio/src/hooks/gsapDragCommit.test.ts | 2 +- packages/studio/src/hooks/gsapDragCommit.ts | 2 +- .../src/hooks/gsapDragPositionCommit.ts | 2 +- .../hooks/gsapKeyframeCacheHelpers.test.ts | 2 +- .../src/hooks/gsapKeyframeCacheHelpers.ts | 2 +- .../studio/src/hooks/gsapKeyframeCommit.ts | 2 +- .../src/hooks/gsapRuntimeBridge.test.ts | 2 +- .../studio/src/hooks/gsapRuntimeBridge.ts | 2 +- .../studio/src/hooks/gsapRuntimeKeyframes.ts | 2 +- .../studio/src/hooks/gsapRuntimeReaders.ts | 4 +- .../src/hooks/gsapScriptCommitHelpers.ts | 2 +- .../studio/src/hooks/gsapScriptCommitTypes.ts | 2 +- packages/studio/src/hooks/gsapShared.ts | 2 +- .../src/hooks/useAnimatedPropertyCommit.ts | 4 +- .../studio/src/hooks/useDomEditCommits.ts | 2 +- .../src/hooks/useEnableKeyframes.test.ts | 2 +- .../studio/src/hooks/useEnableKeyframes.ts | 2 +- packages/studio/src/hooks/useGestureCommit.ts | 4 +- .../useGsapAnimationFetchFallback.test.ts | 2 +- .../hooks/useGsapAnimationFetchFallback.ts | 2 +- .../studio/src/hooks/useGsapAwareEditing.ts | 2 +- .../studio/src/hooks/useGsapKeyframeOps.ts | 2 +- .../src/hooks/useGsapPropertyDebounce.test.ts | 2 +- .../src/hooks/useGsapPropertyDebounce.ts | 2 +- .../studio/src/hooks/useGsapScriptCommits.ts | 2 +- .../src/hooks/useGsapTweenCache.test.ts | 2 +- .../studio/src/hooks/useGsapTweenCache.ts | 10 +- .../src/utils/globalTimeCompiler.test.ts | 2 +- .../studio/src/utils/globalTimeCompiler.ts | 2 +- packages/studio/vite.adapter.ts | 6 +- packages/studio/vite.config.ts | 4 +- 66 files changed, 686 insertions(+), 708 deletions(-) create mode 100644 packages/lint/src/project.ts diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 925915c5ee..39e6b6af70 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -336,11 +336,13 @@ "packages/parsers/src/gsapParser.ts", // htmlParser.ts has pre-existing complexity (moved from packages/core). "packages/parsers/src/htmlParser.ts", - // media.ts and textures.ts: lint rule implementations with pre-existing - // complexity (moved from packages/core/src/lint/rules/). File-level - // exemption avoids the line-shift fingerprint problem for inherited findings. + // lint rule implementations and project linter: pre-existing complexity + // (moved from packages/core/src/lint/). File-level exemption avoids the + // line-shift fingerprint problem for inherited findings. "packages/lint/src/rules/media.ts", "packages/lint/src/rules/textures.ts", + "packages/lint/src/rules/gsap.ts", + "packages/lint/src/project.ts", // studio-server files: pre-existing complexity (moved from packages/core/src/studio-api/). // files.ts: executeGsapMutationRecast/Acorn are CRITICAL; excluded as files.ts // was already in health.ignore at the old path (packages/core/src/studio-api/routes/files.ts). @@ -362,6 +364,22 @@ // body is linear validation that reads clearly inline. "packages/cli/src/commands/play.ts", "packages/cli/src/commands/present.ts", + // Files modified only for import-path updates (one-line changes to switch + // from @hyperframes/core/* subpaths to the new packages). Their complexity + // is pre-existing; the line-shift fingerprint problem makes fallow treat + // the violations as new even though no logic changed. + "packages/cli/src/commands/lint.ts", + "packages/cli/src/commands/preview.ts", + "packages/cli/src/commands/publish.ts", + "packages/cli/src/server/studioServer.ts", + "packages/core/src/core.types.ts", + "packages/core/src/generators/hyperframes.ts", + "packages/producer/src/services/htmlCompiler.ts", + "packages/studio/src/hooks/gsapRuntimeBridge.ts", + "packages/studio/src/hooks/gsapShared.ts", + "packages/studio/src/hooks/gsapDragPositionCommit.ts", + "packages/studio/src/hooks/gsapKeyframeCacheHelpers.ts", + "packages/studio/vite.config.ts", ], }, } diff --git a/bun.lock b/bun.lock index 33712fd910..cf26a0d304 100644 --- a/bun.lock +++ b/bun.lock @@ -82,8 +82,11 @@ "@hyperframes/core": "workspace:*", "@hyperframes/engine": "workspace:*", "@hyperframes/gcp-cloud-run": "workspace:*", + "@hyperframes/lint": "workspace:*", + "@hyperframes/parsers": "workspace:*", "@hyperframes/producer": "workspace:*", "@hyperframes/studio": "workspace:*", + "@hyperframes/studio-server": "workspace:*", "@types/adm-zip": "^0.5.7", "@types/fontkit": "^2.0.9", "@types/mime-types": "^3.0.1", @@ -109,7 +112,6 @@ "@hyperframes/studio-server": "workspace:*", "bpm-detective": "^2.0.5", "postcss": "^8.5.8", - "postcss-selector-parser": "^7.1.2", }, "devDependencies": { "@types/jsdom": "^28.0.0", @@ -231,6 +233,8 @@ "@hono/node-server": "^1.13.0", "@hyperframes/core": "workspace:^", "@hyperframes/engine": "workspace:^", + "@hyperframes/lint": "workspace:^", + "@hyperframes/studio-server": "workspace:^", "hono": "^4.6.0", "linkedom": "^0.18.12", "postcss": "^8.4.0", @@ -259,6 +263,7 @@ "version": "0.7.11", "dependencies": { "@hyperframes/core": "workspace:*", + "@hyperframes/parsers": "workspace:*", "linkedom": "^0.18.12", }, "devDependencies": { @@ -307,8 +312,10 @@ "@codemirror/theme-one-dark": "^6.1.2", "@codemirror/view": "6.40.0", "@hyperframes/core": "workspace:*", + "@hyperframes/parsers": "workspace:*", "@hyperframes/player": "workspace:*", "@hyperframes/sdk": "workspace:*", + "@hyperframes/studio-server": "workspace:*", "@phosphor-icons/react": "^2.1.10", "bpm-detective": "^2.0.5", "dompurify": "^3.2.4", @@ -341,7 +348,6 @@ "version": "0.7.11", "dependencies": { "@hyperframes/core": "workspace:*", - "@hyperframes/lint": "workspace:*", "@hyperframes/parsers": "workspace:*", "hono": "^4.0.0", "linkedom": "^0.18.12", diff --git a/packages/cli/package.json b/packages/cli/package.json index 5d2088ac21..a754e717cc 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -49,8 +49,10 @@ "@hyperframes/core": "workspace:*", "@hyperframes/engine": "workspace:*", "@hyperframes/gcp-cloud-run": "workspace:*", + "@hyperframes/lint": "workspace:*", "@hyperframes/producer": "workspace:*", "@hyperframes/studio": "workspace:*", + "@hyperframes/studio-server": "workspace:*", "@types/adm-zip": "^0.5.7", "@types/fontkit": "^2.0.9", "@types/mime-types": "^3.0.1", diff --git a/packages/cli/src/commands/lint.ts b/packages/cli/src/commands/lint.ts index 73b74555e6..e64a973e9a 100644 --- a/packages/cli/src/commands/lint.ts +++ b/packages/cli/src/commands/lint.ts @@ -38,7 +38,7 @@ export default defineCommand({ async run({ args }) { try { const project = resolveProject(args.dir); - const lintResult = await lintProject(project); + const lintResult = await lintProject(project.dir); if (args.json) { const allFindings = lintResult.results.flatMap((r) => r.result.findings); diff --git a/packages/cli/src/commands/play.ts b/packages/cli/src/commands/play.ts index ca23b55e5b..cee560a586 100644 --- a/packages/cli/src/commands/play.ts +++ b/packages/cli/src/commands/play.ts @@ -107,7 +107,7 @@ export default defineCommand({ const { Hono } = await import("hono"); const { createAdaptorServer } = await import("@hono/node-server"); - const { isSafePath } = await import("@hyperframes/core/studio-api"); + const { isSafePath } = await import("@hyperframes/studio-server"); const app = new Hono(); diff --git a/packages/cli/src/commands/present.ts b/packages/cli/src/commands/present.ts index c96675adcf..2c925aaf37 100644 --- a/packages/cli/src/commands/present.ts +++ b/packages/cli/src/commands/present.ts @@ -101,7 +101,7 @@ export default defineCommand({ const { Hono } = await import("hono"); const { createAdaptorServer } = await import("@hono/node-server"); - const { isSafePath } = await import("@hyperframes/core/studio-api"); + const { isSafePath } = await import("@hyperframes/studio-server"); const app = new Hono(); diff --git a/packages/cli/src/commands/preview.ts b/packages/cli/src/commands/preview.ts index 80749a8a6e..8a26503cda 100644 --- a/packages/cli/src/commands/preview.ts +++ b/packages/cli/src/commands/preview.ts @@ -123,11 +123,10 @@ export default defineCommand({ const isImplicitCwd = !rawArg || rawArg === "." || rawArg === "./"; const project = resolveProject(rawArg); const dir = project.dir; - const indexPath = project.indexPath; const projectName = isImplicitCwd ? basename(process.env.PWD ?? dir) : project.name; // Lint before starting — surface issues for the agent to fix. - const lintResult = await lintProject({ dir, name: projectName, indexPath }); + const lintResult = await lintProject(dir); if (lintResult.totalErrors > 0 || lintResult.totalWarnings > 0) { console.log(); for (const line of formatLintFindings(lintResult)) console.log(line); diff --git a/packages/cli/src/commands/publish.ts b/packages/cli/src/commands/publish.ts index 182743fcc9..260eb082d5 100644 --- a/packages/cli/src/commands/publish.ts +++ b/packages/cli/src/commands/publish.ts @@ -1,4 +1,4 @@ -import { basename, resolve } from "node:path"; +import { resolve } from "node:path"; import { existsSync } from "node:fs"; import { join } from "node:path"; import { defineCommand } from "citty"; @@ -33,12 +33,9 @@ export default defineCommand({ async run({ args }) { const rawArg = args.dir; const dir = resolve(rawArg ?? "."); - const isImplicitCwd = !rawArg || rawArg === "." || rawArg === "./"; - const projectName = isImplicitCwd ? basename(process.env["PWD"] ?? dir) : basename(dir); - const indexPath = join(dir, "index.html"); if (existsSync(indexPath)) { - const lintResult = await lintProject({ dir, name: projectName, indexPath }); + const lintResult = await lintProject(dir); if (lintResult.totalErrors > 0 || lintResult.totalWarnings > 0) { console.log(); for (const line of formatLintFindings(lintResult)) console.log(line); diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index 77660857dd..65f12f67d2 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -738,7 +738,7 @@ export default defineCommand({ // ── Pre-render lint ────────────────────────────────────────────────── { - const lintResult = await lintProject(project); + const lintResult = await lintProject(project.dir); if (!quiet && (lintResult.totalErrors > 0 || lintResult.totalWarnings > 0)) { console.log(""); for (const line of formatLintFindings(lintResult, { errorsFirst: true })) console.log(line); diff --git a/packages/cli/src/server/studioServer.ts b/packages/cli/src/server/studioServer.ts index f25228b562..8fe1d4dd14 100644 --- a/packages/cli/src/server/studioServer.ts +++ b/packages/cli/src/server/studioServer.ts @@ -25,9 +25,9 @@ import { type StudioApiAdapter, type ResolvedProject, type RenderJobState, -} from "@hyperframes/core/studio-api"; -import { getElementScreenshotClip } from "@hyperframes/core/studio-api/screenshot-clip"; -import type { ScreenshotClip } from "@hyperframes/core/studio-api/screenshot-clip"; +} from "@hyperframes/studio-server"; +import { getElementScreenshotClip } from "@hyperframes/studio-server/screenshot-clip"; +import type { ScreenshotClip } from "@hyperframes/studio-server/screenshot-clip"; import type { RenderJob } from "@hyperframes/producer"; const STUDIO_MANUAL_EDITS_PATH = ".hyperframes/studio-manual-edits.json"; @@ -339,7 +339,7 @@ export function createStudioServer(options: StudioServerOptions): StudioServer { }, async lint(html: string, opts?: { filePath?: string }) { - const { lintHyperframeHtml } = await import("@hyperframes/core/lint"); + const { lintHyperframeHtml } = await import("@hyperframes/lint"); return await lintHyperframeHtml(html, opts); }, diff --git a/packages/cli/src/utils/lintFormat.test.ts b/packages/cli/src/utils/lintFormat.test.ts index f967c753a6..bab09e3b72 100644 --- a/packages/cli/src/utils/lintFormat.test.ts +++ b/packages/cli/src/utils/lintFormat.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import type { HyperframeLintFinding } from "@hyperframes/core/lint"; +import type { HyperframeLintFinding } from "@hyperframes/lint"; import { formatLintFindings } from "./lintFormat.js"; import type { ProjectLintResult } from "./lintProject.js"; diff --git a/packages/cli/src/utils/lintProject.test.ts b/packages/cli/src/utils/lintProject.test.ts index aec22d15e2..2491b947aa 100644 --- a/packages/cli/src/utils/lintProject.test.ts +++ b/packages/cli/src/utils/lintProject.test.ts @@ -3,7 +3,6 @@ import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { lintProject, shouldBlockRender } from "./lintProject.js"; -import type { ProjectDir } from "./project.js"; function tmpProject(name: string): string { return mkdtempSync(join(tmpdir(), `hf-test-${name}-`)); @@ -37,7 +36,7 @@ function htmlWithPreloadNone(): string { let dirs: string[] = []; -function makeProject(indexHtml: string, subComps?: Record): ProjectDir { +function makeProject(indexHtml: string, subComps?: Record): string { const dir = tmpProject("lint"); dirs.push(dir); writeFileSync(join(dir, "index.html"), indexHtml); @@ -48,7 +47,7 @@ function makeProject(indexHtml: string, subComps?: Record): Proj writeFileSync(join(compsDir, name), html); } } - return { dir, name: "test-project", indexPath: join(dir, "index.html") }; + return dir; } afterEach(() => { @@ -108,12 +107,7 @@ describe("lintProject", () => { `; writeFileSync(join(framesDir, "04-mechanism.html"), frameHtml); - const project: ProjectDir = { - dir, - name: "test-project", - indexPath: join(dir, "index.html"), - }; - const { results } = await lintProject(project); + const { results } = await lintProject(dir); const frameResult = results.find((r) => r.file === "compositions/frames/04-mechanism.html"); expect(frameResult).toBeDefined(); @@ -146,7 +140,7 @@ describe("lintProject", () => { `, }); writeFileSync( - join(project.dir, "compositions", "scene.css"), + join(project, "compositions", "scene.css"), '[data-composition-id="scene"] .title { opacity: 0; }', ); @@ -169,7 +163,7 @@ describe("lintProject", () => { `, }); writeFileSync( - join(project.dir, "compositions", decodeURIComponent(encodedFilename)), + join(project, "compositions", decodeURIComponent(encodedFilename)), '[data-composition-id="scene"] .title { opacity: 0; }', ); @@ -227,7 +221,7 @@ describe("lintProject", () => { "captions.html": validHtml("captions"), }); // Add a non-HTML file - writeFileSync(join(project.dir, "compositions", "readme.txt"), "not html"); + writeFileSync(join(project, "compositions", "readme.txt"), "not html"); const { results } = await lintProject(project); @@ -270,7 +264,7 @@ function validHtmlWithMaskImageUrl(url: string): string { describe("audio_file_without_element", () => { it("warns when audio file exists but no