diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 89a2c79a8c..d393d3acb2 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,23 @@ // 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", + // 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", + "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 @@ -276,26 +299,18 @@ ], }, "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. - // - // 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 - // flag-gated behind STUDIO_SDK_CUTOVER_ENABLED). Its complexity pre-dates - // this PR and was present on all ancestor branches; the file-level exemption - // avoids the line-shift fingerprint problem for inherited findings. + // rather than refactored as scope creep. "ignore": [ + // gsapParser.ts: the recast/babel GSAP writer is a 2500-line legacy parser; + // moved from packages/core/src/parsers/ — same complexity rationale. + "packages/parsers/src/gsapParser.ts", + // htmlParser.ts has pre-existing complexity (moved from packages/core). + "packages/parsers/src/htmlParser.ts", + // executeGsapMutation (CRITICAL) pre-dates this PR; studio-api still lives in core. "packages/core/src/studio-api/routes/files.ts", - "packages/core/src/parsers/gsapParser.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 @@ -313,6 +328,23 @@ // vs base-var args, validation throws) but small and well-tested via the // generated table's shape test; this is dev tooling, not shipped runtime. "packages/cli/scripts/sync-agent-dirs.ts", + // Files modified only for import-path updates (one-line changes to switch + // from @hyperframes/core/* subpaths to @hyperframes/parsers). Their complexity + // is pre-existing; the line-shift fingerprint problem makes fallow treat + // the violations as new even though no logic changed. + "packages/core/src/core.types.ts", + "packages/core/src/generators/hyperframes.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", + // set-version.ts: compareSemver helper has pre-existing complexity from + // semver string parsing logic; line-shift fingerprint problem from new + // packages added to PACKAGES array makes fallow treat it as new. + "scripts/set-version.ts", + // gsapRuntimeReaders.ts: pre-existing complexity in readAllAnimatedProperties; + // line-shift fingerprint from import-path updates triggers the violation. + "packages/studio/src/hooks/gsapRuntimeReaders.ts", ], }, } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c39a70a910..77e6fa1c0e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -227,6 +227,8 @@ jobs: - uses: ./.github/actions/prepare-ffmpeg-bin - run: bun install --frozen-lockfile - run: bun run test:scripts + - run: bun run --filter '@hyperframes/parsers' build + - run: bun run --cwd packages/core build - run: bun run --cwd packages/core build:hyperframes-runtime - run: bun run --filter '!@hyperframes/producer' test @@ -315,6 +317,10 @@ jobs: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 - run: bun install --frozen-lockfile + # Build workspace deps so the sdk's @hyperframes/parsers + core subpath + # imports resolve via the "node" export condition (dist) under vitest. + - run: bun run --filter '@hyperframes/parsers' build + - run: bun run --cwd packages/core build - run: bun run --filter @hyperframes/sdk test test-runtime-contract: @@ -351,6 +357,10 @@ jobs: node-version: 22 - uses: ./.github/actions/prepare-ffmpeg-bin - run: bun install --frozen-lockfile + # Build workspace deps so the studio vite.config.ts (loaded by Node) can + # resolve @hyperframes/core via the "node" export condition (dist). + - run: bun run --filter '@hyperframes/parsers' build + - run: bun run --cwd packages/core build - run: bun run --cwd packages/core build:hyperframes-runtime - name: Start studio and check for runtime errors run: | diff --git a/.github/workflows/preview-regression.yml b/.github/workflows/preview-regression.yml index 5ebb83d40e..5ee4f10030 100644 --- a/.github/workflows/preview-regression.yml +++ b/.github/workflows/preview-regression.yml @@ -36,6 +36,7 @@ jobs: filters: | preview: - "packages/core/**" + - "packages/parsers/**" - "packages/player/**" - "packages/studio/**" - "packages/cli/**" @@ -73,6 +74,11 @@ jobs: - run: bun install --frozen-lockfile + - name: Build workspace packages (required for vite config loading) + run: | + bun run --filter '@hyperframes/parsers' build + bun run --cwd packages/core build + - name: Run Studio preview routing regression run: | bun run --cwd packages/studio test -- vite.thumbnail.test.ts src/utils/projectRouting.test.ts src/utils/frameCapture.test.ts diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 32d6b8445d..42123e75f1 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -124,6 +124,7 @@ jobs: fi } + publish_pkg "@hyperframes/parsers" "@hyperframes/parsers" publish_pkg "@hyperframes/core" "@hyperframes/core" publish_pkg "@hyperframes/sdk" "@hyperframes/sdk" publish_pkg "@hyperframes/engine" "@hyperframes/engine" diff --git a/Dockerfile.test b/Dockerfile.test index 36b5fea3c4..5748109914 100644 --- a/Dockerfile.test +++ b/Dockerfile.test @@ -74,6 +74,7 @@ ENV PATH="/root/.bun/bin:$PATH" # --frozen-lockfile` treats any member missing from the build context as a # lockfile change and fails. COPY package.json bun.lock ./ +COPY packages/parsers/package.json packages/parsers/package.json COPY packages/core/package.json packages/core/package.json COPY packages/engine/package.json packages/engine/package.json COPY packages/player/package.json packages/player/package.json @@ -88,10 +89,15 @@ COPY packages/sdk-playground/package.json packages/sdk-playground/package.json RUN bun install --frozen-lockfile # Copy source +COPY packages/parsers/ packages/parsers/ COPY packages/core/ packages/core/ COPY packages/engine/ packages/engine/ COPY packages/producer/ packages/producer/ +# Build workspace packages so "node" export conditions resolve to built dist +RUN bun run --filter '@hyperframes/parsers' build \ + && bun run --cwd packages/core build + # Build core runtime artifacts (needed by renderer) RUN bun run --filter @hyperframes/core build:hyperframes-runtime:modular diff --git a/bun.lock b/bun.lock index 92e55ee580..35c28338b2 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.13", "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.13", "bin": { "hyperframes": "./dist/cli.js", }, @@ -101,17 +101,14 @@ }, "packages/core": { "name": "@hyperframes/core", - "version": "0.6.119", + "version": "0.7.13", "dependencies": { - "@babel/parser": "^7.27.0", "@chenglou/pretext": "^0.0.5", - "acorn": "^8.17.0", - "acorn-walk": "^8.3.5", + "@hyperframes/parsers": "workspace:*", "bpm-detective": "^2.0.5", - "magic-string": "^0.30.21", + "linkedom": "^0.18.12", "postcss": "^8.5.8", "postcss-selector-parser": "^7.1.2", - "recast": "^0.23.11", }, "devDependencies": { "@types/jsdom": "^28.0.0", @@ -124,7 +121,6 @@ }, "optionalDependencies": { "esbuild": "^0.25.12", - "linkedom": "^0.18.12", }, "peerDependencies": { "hono": "^4.0.0", @@ -135,7 +131,7 @@ }, "packages/engine": { "name": "@hyperframes/engine", - "version": "0.6.119", + "version": "0.7.13", "dependencies": { "@hono/node-server": "^1.13.0", "@hyperframes/core": "workspace:^", @@ -153,7 +149,7 @@ }, "packages/gcp-cloud-run": { "name": "@hyperframes/gcp-cloud-run", - "version": "0.6.119", + "version": "0.7.13", "dependencies": { "@google-cloud/storage": "^7.14.0", "@google-cloud/workflows": "^4.2.0", @@ -171,9 +167,29 @@ "typescript": "^5.7.2", }, }, + "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", + "typescript": "^5.0.0", + "vitest": "^3.2.4", + }, + }, "packages/player": { "name": "@hyperframes/player", - "version": "0.6.119", + "version": "0.7.13", "dependencies": { "@hyperframes/core": "workspace:*", }, @@ -188,7 +204,7 @@ }, "packages/producer": { "name": "@hyperframes/producer", - "version": "0.6.119", + "version": "0.7.13", "dependencies": { "@fontsource/archivo-black": "^5.2.8", "@fontsource/eb-garamond": "^5.2.7", @@ -229,9 +245,10 @@ }, "packages/sdk": { "name": "@hyperframes/sdk", - "version": "0.6.119", + "version": "0.7.13", "dependencies": { "@hyperframes/core": "workspace:*", + "@hyperframes/parsers": "workspace:*", "linkedom": "^0.18.12", }, "devDependencies": { @@ -254,7 +271,7 @@ }, "packages/shader-transitions": { "name": "@hyperframes/shader-transitions", - "version": "0.6.119", + "version": "0.7.13", "dependencies": { "html2canvas": "^1.4.1", }, @@ -266,7 +283,7 @@ }, "packages/studio": { "name": "@hyperframes/studio", - "version": "0.6.119", + "version": "0.7.13", "dependencies": { "@codemirror/autocomplete": "^6.20.1", "@codemirror/commands": "^6.10.3", @@ -280,6 +297,7 @@ "@codemirror/theme-one-dark": "^6.1.2", "@codemirror/view": "6.40.0", "@hyperframes/core": "workspace:*", + "@hyperframes/parsers": "workspace:*", "@hyperframes/player": "workspace:*", "@hyperframes/sdk": "workspace:*", "@phosphor-icons/react": "^2.1.10", @@ -667,6 +685,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/package.json b/package.json index d5f5d222f0..ac70315d6a 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "type": "module", "scripts": { "dev": "bun run studio", - "build": "bun run --filter @hyperframes/core build && bun run --filter '@hyperframes/{core,engine,producer,player,studio,shader-transitions,aws-lambda,gcp-cloud-run,sdk}' build && bun run --filter @hyperframes/cli build", + "build": "bun run --filter @hyperframes/parsers build && bun run --filter @hyperframes/core build && bun run --filter '@hyperframes/{core,engine,producer,player,studio,shader-transitions,aws-lambda,gcp-cloud-run,sdk}' build && bun run --filter @hyperframes/cli build", "build:producer": "bun run --filter @hyperframes/producer build", "studio": "bun run --filter @hyperframes/studio dev", "build:hyperframes-runtime": "bun run --filter @hyperframes/core build:hyperframes-runtime", diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts index 6e3fc00e59..faa734199d 100644 --- a/packages/cli/tsup.config.ts +++ b/packages/cli/tsup.config.ts @@ -61,6 +61,7 @@ var __dirname = __hf_dirname(__filename);`, ], noExternal: [ "@hyperframes/core", + "@hyperframes/parsers", "@hyperframes/producer", "@hyperframes/engine", "@clack/prompts", diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts index ae847ff6d9..0ef07511c0 100644 --- a/packages/cli/vitest.config.ts +++ b/packages/cli/vitest.config.ts @@ -1,6 +1,21 @@ +import { resolve } from "node:path"; import { defineConfig } from "vitest/config"; export default defineConfig({ + resolve: { + alias: [ + // Resolve the bare @hyperframes/core entry to TypeScript source, not built + // dist. The published dist intentionally omits runtime/entry.ts, so the + // dist build of loadHyperframeRuntimeSource() returns null — which makes + // studioServer.test.ts's runtime-source equality assertion diverge. Tests + // run under bun against source; subpath imports (@hyperframes/core/*) keep + // resolving via the package's export conditions. + { + find: /^@hyperframes\/core$/, + replacement: resolve(__dirname, "../core/src/index.ts"), + }, + ], + }, test: { include: ["src/**/*.test.ts"], }, diff --git a/packages/core/package.json b/packages/core/package.json index ad1ebd164f..b188ebabd7 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -8,7 +8,13 @@ "directory": "packages/core" }, "files": [ - "dist", + "dist/**/*.js", + "dist/**/*.d.ts", + "dist/**/*.d.ts.map", + "dist/**/*.js.map", + "!dist/hyperframe.runtime.iife.js", + "!dist/hyperframe.runtime.mjs", + "!dist/generated/runtime-inline.js", "docs", "schemas", "README.md" @@ -18,116 +24,178 @@ "types": "./src/index.ts", "exports": { ".": { + "bun": "./src/index.ts", + "node": "./dist/index.js", "import": "./src/index.ts", "types": "./src/index.ts" }, "./package.json": "./package.json", "./beats": { + "bun": "./src/beats/index.ts", + "node": "./dist/beats/index.js", "import": "./src/beats/index.ts", "types": "./src/beats/index.ts" }, "./html-attr-safety": { + "bun": "./src/utils/htmlAttrSafety.ts", + "node": "./dist/utils/htmlAttrSafety.js", "import": "./src/utils/htmlAttrSafety.ts", "types": "./src/utils/htmlAttrSafety.ts" }, "./slideshow": { + "bun": "./src/slideshow/index.ts", + "node": "./dist/slideshow/index.js", "import": "./src/slideshow/index.ts", "types": "./src/slideshow/index.ts" }, + "./generators": { + "bun": "./src/generators/hyperframes.ts", + "node": "./dist/generators/hyperframes.js", + "import": "./src/generators/hyperframes.ts", + "types": "./src/generators/hyperframes.ts" + }, "./lint": { + "bun": "./src/lint/index.ts", + "node": "./dist/lint/index.js", "import": "./src/lint/index.ts", "types": "./src/lint/index.ts" }, "./compiler": { + "bun": "./src/compiler/index.ts", + "node": "./dist/compiler/index.js", "import": "./src/compiler/index.ts", "types": "./src/compiler/index.ts" }, "./color-grading": { + "bun": "./src/colorGrading.ts", + "node": "./dist/colorGrading.js", "import": "./src/colorGrading.ts", "types": "./src/colorGrading.ts" }, "./color-luts": { + "bun": "./src/colorLuts.ts", + "node": "./dist/colorLuts.js", "import": "./src/colorLuts.ts", "types": "./src/colorLuts.ts" }, "./storyboard": { + "bun": "./src/storyboard/index.ts", + "node": "./dist/storyboard/index.js", "import": "./src/storyboard/index.ts", "types": "./src/storyboard/index.ts" }, "./runtime": "./dist/hyperframe.runtime.iife.js", "./runtime/clipTree": { + "bun": "./src/runtime/clipTree.ts", + "node": "./dist/runtime/clipTree.js", "import": "./src/runtime/clipTree.ts", "types": "./src/runtime/clipTree.ts" }, "./runtime/lottie-readiness": { + "bun": "./src/lottieReadiness.ts", + "node": "./dist/lottieReadiness.js", "import": "./src/lottieReadiness.ts", "types": "./src/lottieReadiness.ts" }, "./studio-api": { + "bun": "./src/studio-api/index.ts", + "node": "./dist/studio-api/index.js", "import": "./src/studio-api/index.ts", "types": "./src/studio-api/index.ts" }, "./studio-api/screenshot-clip": { + "bun": "./src/studio-api/helpers/screenshotClip.ts", + "node": "./dist/studio-api/helpers/screenshotClip.js", "import": "./src/studio-api/helpers/screenshotClip.ts", "types": "./src/studio-api/helpers/screenshotClip.ts" }, "./studio-api/manual-edits-render-script": { + "bun": "./src/studio-api/helpers/manualEditsRenderScript.ts", + "node": "./dist/studio-api/helpers/manualEditsRenderScript.js", "import": "./src/studio-api/helpers/manualEditsRenderScript.ts", "types": "./src/studio-api/helpers/manualEditsRenderScript.ts" }, "./studio-api/studio-motion-render-script": { + "bun": "./src/studio-api/helpers/studioMotionRenderScript.ts", + "node": "./dist/studio-api/helpers/studioMotionRenderScript.js", "import": "./src/studio-api/helpers/studioMotionRenderScript.ts", "types": "./src/studio-api/helpers/studioMotionRenderScript.ts" }, "./studio-api/draft-markers": { + "bun": "./src/studio-api/helpers/draftMarkers.ts", + "node": "./dist/studio-api/helpers/draftMarkers.js", "import": "./src/studio-api/helpers/draftMarkers.ts", "types": "./src/studio-api/helpers/draftMarkers.ts" }, "./studio-api/finite-mutation": { + "bun": "./src/studio-api/helpers/finiteMutation.ts", + "node": "./dist/studio-api/helpers/finiteMutation.js", "import": "./src/studio-api/helpers/finiteMutation.ts", "types": "./src/studio-api/helpers/finiteMutation.ts" }, "./text": { + "bun": "./src/text/index.ts", + "node": "./dist/text/index.js", "import": "./src/text/index.ts", "types": "./src/text/index.ts" }, "./registry": { + "bun": "./src/registry/index.ts", + "node": "./dist/registry/index.js", "import": "./src/registry/index.ts", "types": "./src/registry/index.ts" }, "./media-volume-envelope": { + "bun": "./src/runtime/mediaVolumeEnvelope.ts", + "node": "./dist/runtime/mediaVolumeEnvelope.js", "import": "./src/runtime/mediaVolumeEnvelope.ts", "types": "./src/runtime/mediaVolumeEnvelope.ts" }, "./hf-ids": { + "bun": "./src/parsers/hfIds.ts", + "node": "./dist/parsers/hfIds.js", "import": "./src/parsers/hfIds.ts", "types": "./src/parsers/hfIds.ts" }, "./gsap-parser": { + "bun": "./src/parsers/gsapParserExports.ts", + "node": "./dist/parsers/gsapParserExports.js", "import": "./src/parsers/gsapParserExports.ts", "types": "./src/parsers/gsapParserExports.ts" }, "./gsap-parser-acorn": { + "bun": "./src/parsers/gsapParserAcorn.ts", + "node": "./dist/parsers/gsapParserAcorn.js", "import": "./src/parsers/gsapParserAcorn.ts", "types": "./src/parsers/gsapParserAcorn.ts" }, "./gsap-writer-acorn": { + "bun": "./src/parsers/gsapWriterAcorn.ts", + "node": "./dist/parsers/gsapWriterAcorn.js", "import": "./src/parsers/gsapWriterAcorn.ts", "types": "./src/parsers/gsapWriterAcorn.ts" }, "./gsap-constants": { + "bun": "./src/parsers/gsapConstants.ts", + "node": "./dist/parsers/gsapConstants.js", "import": "./src/parsers/gsapConstants.ts", "types": "./src/parsers/gsapConstants.ts" }, "./spring-ease": { + "bun": "./src/parsers/springEase.ts", + "node": "./dist/parsers/springEase.js", "import": "./src/parsers/springEase.ts", "types": "./src/parsers/springEase.ts" }, "./fonts/aliases": { + "bun": "./src/fonts/aliases.ts", + "node": "./dist/fonts/aliases.js", "import": "./src/fonts/aliases.ts", "types": "./src/fonts/aliases.ts" }, "./fonts/system-locator": { + "bun": "./src/fonts/systemFontLocator.ts", + "node": "./dist/fonts/systemFontLocator.js", "import": "./src/fonts/systemFontLocator.ts", "types": "./src/fonts/systemFontLocator.ts" }, @@ -150,6 +218,10 @@ "import": "./dist/utils/htmlAttrSafety.js", "types": "./dist/utils/htmlAttrSafety.d.ts" }, + "./generators": { + "import": "./dist/generators/hyperframes.js", + "types": "./dist/generators/hyperframes.d.ts" + }, "./lint": { "import": "./dist/lint/index.js", "types": "./dist/lint/index.d.ts" @@ -280,15 +352,12 @@ "prepublishOnly": "echo skip" }, "dependencies": { - "@babel/parser": "^7.27.0", "@chenglou/pretext": "^0.0.5", - "acorn": "^8.17.0", - "acorn-walk": "^8.3.5", + "@hyperframes/parsers": "workspace:*", "bpm-detective": "^2.0.5", - "magic-string": "^0.30.21", + "linkedom": "^0.18.12", "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", @@ -308,7 +377,6 @@ } }, "optionalDependencies": { - "esbuild": "^0.25.12", - "linkedom": "^0.18.12" + "esbuild": "^0.25.12" } } 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/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/lint/rules/gsap.ts b/packages/core/src/lint/rules/gsap.ts index 570146d1c6..ef792db500 100644 --- a/packages/core/src/lint/rules/gsap.ts +++ b/packages/core/src/lint/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/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/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/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/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/studio-api/helpers/hfIdPersist.ts b/packages/core/src/studio-api/helpers/hfIdPersist.ts index 22b8a27464..b60e21920b 100644 --- a/packages/core/src/studio-api/helpers/hfIdPersist.ts +++ b/packages/core/src/studio-api/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/routes/files.ts b/packages/core/src/studio-api/routes/files.ts index 08d4fdd202..18f0ff6484 100644 --- a/packages/core/src/studio-api/routes/files.ts +++ b/packages/core/src/studio-api/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 ────────────────────────────────────────────────────────── diff --git a/packages/core/src/studio-api/routes/preview.ts b/packages/core/src/studio-api/routes/preview.ts index d561808a80..fb6e11dc97 100644 --- a/packages/core/src/studio-api/routes/preview.ts +++ b/packages/core/src/studio-api/routes/preview.ts @@ -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/parsers/package.json b/packages/parsers/package.json new file mode 100644 index 0000000000..ecba91e9bc --- /dev/null +++ b/packages/parsers/package.json @@ -0,0 +1,130 @@ +{ + "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": { + ".": { + "bun": "./src/index.ts", + "node": "./dist/index.js", + "import": "./src/index.ts", + "types": "./src/index.ts" + }, + "./package.json": "./package.json", + "./gsap-parser": { + "bun": "./src/gsapParserExports.ts", + "node": "./dist/gsapParserExports.js", + "import": "./src/gsapParserExports.ts", + "types": "./src/gsapParserExports.ts" + }, + "./gsap-parser-acorn": { + "bun": "./src/gsapParserAcorn.ts", + "node": "./dist/gsapParserAcorn.js", + "import": "./src/gsapParserAcorn.ts", + "types": "./src/gsapParserAcorn.ts" + }, + "./gsap-writer-acorn": { + "bun": "./src/gsapWriterAcorn.ts", + "node": "./dist/gsapWriterAcorn.js", + "import": "./src/gsapWriterAcorn.ts", + "types": "./src/gsapWriterAcorn.ts" + }, + "./gsap-constants": { + "bun": "./src/gsapConstants.ts", + "node": "./dist/gsapConstants.js", + "import": "./src/gsapConstants.ts", + "types": "./src/gsapConstants.ts" + }, + "./spring-ease": { + "bun": "./src/springEase.ts", + "node": "./dist/springEase.js", + "import": "./src/springEase.ts", + "types": "./src/springEase.ts" + }, + "./hf-ids": { + "bun": "./src/hfIds.ts", + "node": "./dist/hfIds.js", + "import": "./src/hfIds.ts", + "types": "./src/hfIds.ts" + }, + "./gsap-parser-recast": { + "bun": "./src/gsapParser.ts", + "node": "./dist/gsapParser.js", + "import": "./src/gsapParser.ts", + "types": "./src/gsapParser.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" + }, + "./gsap-parser-recast": { + "import": "./dist/gsapParser.js", + "types": "./dist/gsapParser.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": { + "@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", + "typescript": "^5.0.0", + "vitest": "^3.2.4" + } +} 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/core/src/parsers/gsapParser.ts b/packages/parsers/src/gsapParser.ts similarity index 100% rename from packages/core/src/parsers/gsapParser.ts rename to packages/parsers/src/gsapParser.ts 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/core/src/parsers/gsapSerialize.ts b/packages/parsers/src/gsapSerialize.ts similarity index 99% rename from packages/core/src/parsers/gsapSerialize.ts rename to packages/parsers/src/gsapSerialize.ts index c5832c7156..0595961bdd 100644 --- a/packages/core/src/parsers/gsapSerialize.ts +++ b/packages/parsers/src/gsapSerialize.ts @@ -6,7 +6,7 @@ * 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 { Keyframe, KeyframeProperties, ValidationResult } from "./types.js"; import type { PropertyGroupName } from "./gsapConstants"; export type GsapMethod = "set" | "to" | "from" | "fromTo"; 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/core/src/parsers/gsapUnroll.ts b/packages/parsers/src/gsapUnroll.ts similarity index 100% rename from packages/core/src/parsers/gsapUnroll.ts rename to packages/parsers/src/gsapUnroll.ts 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..c84ad7f969 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/generators"; 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/core/src/parsers/htmlParser.ts b/packages/parsers/src/htmlParser.ts similarity index 99% rename from packages/core/src/parsers/htmlParser.ts rename to packages/parsers/src/htmlParser.ts index 146af3f206..437778ae71 100644 --- a/packages/core/src/parsers/htmlParser.ts +++ b/packages/parsers/src/htmlParser.ts @@ -9,13 +9,13 @@ import type { KeyframeProperties, StageZoomKeyframe, CompositionVariable, -} from "../core.types"; + ValidationResult, +} from "./types.js"; import { validateCompositionGsap } from "./gsapSerialize"; import { ensureHfIds } from "./hfIds.js"; import { parseGsapScriptAcornForWrite } from "./gsapParserAcorn.js"; -import { queryByAttr } from "../utils/cssSelector"; +import { queryByAttr } from "./utils/cssSelector.js"; import { removeAnimationFromScript } from "./gsapWriterAcorn.js"; -import type { ValidationResult } from "../core.types"; const MEDIA_TYPES = new Set(["video", "image", "audio"]); diff --git a/packages/parsers/src/index.ts b/packages/parsers/src/index.ts new file mode 100644 index 0000000000..f7498a9170 --- /dev/null +++ b/packages/parsers/src/index.ts @@ -0,0 +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..57ae89c7ff 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/generators"; import type { ParsedHtml } from "./htmlParser.js"; export function maxEndTime(elements: ParsedHtml["elements"]): number { 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, + }; +} 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/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..3fee07ef05 --- /dev/null +++ b/packages/parsers/tsup.config.ts @@ -0,0 +1,23 @@ +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", + gsapParser: "src/gsapParser.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", + }, +}); diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 222434bff0..4775c4584b 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -63,6 +63,7 @@ }, "dependencies": { "@hyperframes/core": "workspace:*", + "@hyperframes/parsers": "workspace:*", "linkedom": "^0.18.12" }, "devDependencies": { diff --git a/packages/sdk/src/document.ts b/packages/sdk/src/document.ts index c84adc141a..ea227b8272 100644 --- a/packages/sdk/src/document.ts +++ b/packages/sdk/src/document.ts @@ -9,7 +9,7 @@ */ import { parseHTML } from "linkedom"; -import { ensureHfIds } from "@hyperframes/core/hf-ids"; +import { ensureHfIds } from "@hyperframes/parsers/hf-ids"; import { parseGsapScriptAcornForWrite } from "@hyperframes/core/gsap-parser-acorn"; import { findRoot, getElementStyles, getOwnText, isNewHostBoundary } from "./engine/model.js"; import type { HyperFramesElement, SdkDocument } from "./types.js"; diff --git a/packages/studio/package.json b/packages/studio/package.json index 3b87e6b662..db7881ce4d 100644 --- a/packages/studio/package.json +++ b/packages/studio/package.json @@ -55,6 +55,7 @@ "@codemirror/theme-one-dark": "^6.1.2", "@codemirror/view": "6.40.0", "@hyperframes/core": "workspace:*", + "@hyperframes/parsers": "workspace:*", "@hyperframes/player": "workspace:*", "@hyperframes/sdk": "workspace:*", "@phosphor-icons/react": "^2.1.10", diff --git a/packages/studio/src/components/editor/domEditingTypes.ts b/packages/studio/src/components/editor/domEditingTypes.ts index 50d82cafa1..da52f4f2af 100644 --- a/packages/studio/src/components/editor/domEditingTypes.ts +++ b/packages/studio/src/components/editor/domEditingTypes.ts @@ -1,5 +1,5 @@ import type { PatchTarget } from "../../utils/sourcePatcher"; -import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; +import type { GsapAnimation } from "@hyperframes/parsers/gsap-parser"; export const CURATED_STYLE_PROPERTIES = [ "position", diff --git a/packages/studio/src/components/editor/gsapAnimationCallbacks.ts b/packages/studio/src/components/editor/gsapAnimationCallbacks.ts index ebdc448181..e35f5cdd6e 100644 --- a/packages/studio/src/components/editor/gsapAnimationCallbacks.ts +++ b/packages/studio/src/components/editor/gsapAnimationCallbacks.ts @@ -1,4 +1,4 @@ -import type { ArcPathSegment } from "@hyperframes/core/gsap-parser"; +import type { ArcPathSegment } from "@hyperframes/parsers/gsap-parser"; /** * Edit callbacks shared by GsapAnimationSection and each AnimationCard it diff --git a/packages/studio/src/components/editor/gsapAnimationHelpers.test.ts b/packages/studio/src/components/editor/gsapAnimationHelpers.test.ts index 83e5296895..c60fbbae95 100644 --- a/packages/studio/src/components/editor/gsapAnimationHelpers.test.ts +++ b/packages/studio/src/components/editor/gsapAnimationHelpers.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from "vitest"; -import { SUPPORTED_PROPS } from "@hyperframes/core/gsap-constants"; +import { SUPPORTED_PROPS } from "@hyperframes/parsers/gsap-constants"; import { buildTweenSummary } from "./gsapAnimationHelpers"; import { PROP_LABELS } from "./gsapAnimationConstants"; -import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; +import type { GsapAnimation } from "@hyperframes/parsers/gsap-parser"; function anim(overrides: Partial): GsapAnimation { return { diff --git a/packages/studio/src/components/editor/gsapAnimationHelpers.ts b/packages/studio/src/components/editor/gsapAnimationHelpers.ts index 911cbcd3c5..ac9c2b5e37 100644 --- a/packages/studio/src/components/editor/gsapAnimationHelpers.ts +++ b/packages/studio/src/components/editor/gsapAnimationHelpers.ts @@ -1,4 +1,4 @@ -import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; +import type { GsapAnimation } from "@hyperframes/parsers/gsap-parser"; import { EASE_LABELS, PERCENT_PROPS, PROP_LABELS, PROP_UNITS } from "./gsapAnimationConstants"; function formatPropValue(prop: string, v: number | string): string { diff --git a/packages/studio/src/components/editor/motionPathCommit.test.ts b/packages/studio/src/components/editor/motionPathCommit.test.ts index 6de7acdba9..5eae75de99 100644 --- a/packages/studio/src/components/editor/motionPathCommit.test.ts +++ b/packages/studio/src/components/editor/motionPathCommit.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from "vitest"; -import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; +import type { GsapAnimation } from "@hyperframes/parsers/gsap-parser"; import { editableAnimationId } from "./motionPathSelection"; import { commitNode, diff --git a/packages/studio/src/components/editor/motionPathSelection.ts b/packages/studio/src/components/editor/motionPathSelection.ts index 333290be44..10d193d50f 100644 --- a/packages/studio/src/components/editor/motionPathSelection.ts +++ b/packages/studio/src/components/editor/motionPathSelection.ts @@ -3,7 +3,7 @@ * Shared by the overlay and its diagnostics (kept here to avoid a circular * import between the two). */ -import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; +import type { GsapAnimation } from "@hyperframes/parsers/gsap-parser"; import type { DomEditSelection } from "./domEditing"; export function selectorFor(sel: DomEditSelection | null): string | null { diff --git a/packages/studio/src/components/editor/propertyPanelHelpers.ts b/packages/studio/src/components/editor/propertyPanelHelpers.ts index b0944f2573..0899ec779e 100644 --- a/packages/studio/src/components/editor/propertyPanelHelpers.ts +++ b/packages/studio/src/components/editor/propertyPanelHelpers.ts @@ -2,7 +2,7 @@ import { parseCssColor, type ParsedColor } from "./colorValue"; import { COMMON_LOCAL_FONT_FAMILIES } from "./fontCatalog"; import type { DomEditSelection } from "./domEditing"; import type { ImportedFontAsset } from "./fontAssets"; -import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; +import type { GsapAnimation } from "@hyperframes/parsers/gsap-parser"; import { roundToCenti } from "../../utils/rounding"; export interface PropertyPanelProps { @@ -29,7 +29,7 @@ export interface PropertyPanelProps { fontAssets?: ImportedFontAsset[]; onImportFonts?: (files: FileList | File[]) => Promise; previewIframeRef?: React.RefObject; - gsapAnimations?: import("@hyperframes/core/gsap-parser").GsapAnimation[]; + gsapAnimations?: import("@hyperframes/parsers/gsap-parser").GsapAnimation[]; gsapMultipleTimelines?: boolean; gsapUnsupportedTimelinePattern?: boolean; onUpdateGsapProperty?: (animId: string, prop: string, value: number | string) => void; @@ -49,13 +49,13 @@ export interface PropertyPanelProps { config: { enabled: boolean; autoRotate?: boolean | number; - segments?: import("@hyperframes/core/gsap-parser").ArcPathSegment[]; + segments?: import("@hyperframes/parsers/gsap-parser").ArcPathSegment[]; }, ) => void; onUpdateArcSegment?: ( animId: string, segmentIndex: number, - update: Partial, + update: Partial, ) => void; /** Unroll computed (helper/loop) tweens into literal tweens for direct editing. */ onUnroll?: (animationId: string) => void; diff --git a/scripts/set-version.ts b/scripts/set-version.ts index 7bea3bab6b..e43348e2b3 100644 --- a/scripts/set-version.ts +++ b/scripts/set-version.ts @@ -21,6 +21,7 @@ import { pathToFileURL } from "url"; import { CLI_SEMVER_PATTERN } from "./cli-options.ts"; const PACKAGES = [ + "packages/parsers", "packages/core", "packages/engine", "packages/player",