chore: import upstream snapshot with attribution
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Docs / Validate docs (push) Has been cancelled
Sync skills to ClawHub / Publish changed skills (push) Has been cancelled
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Docs / Validate docs (push) Has been cancelled
Sync skills to ClawHub / Publish changed skills (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
{
|
||||
"name": "@hyperframes/parsers",
|
||||
"version": "0.7.55",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/heygen-com/hyperframes",
|
||||
"directory": "packages/parsers"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md"
|
||||
],
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"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"
|
||||
},
|
||||
"./slideshow": {
|
||||
"bun": "./src/slideshow/index.ts",
|
||||
"node": "./dist/slideshow.js",
|
||||
"import": "./src/slideshow/index.ts",
|
||||
"types": "./src/slideshow/index.ts"
|
||||
},
|
||||
"./asset-paths": {
|
||||
"bun": "./src/assets.ts",
|
||||
"node": "./dist/assets.js",
|
||||
"import": "./src/assets.ts",
|
||||
"types": "./src/assets.ts"
|
||||
},
|
||||
"./composition": {
|
||||
"bun": "./src/composition.ts",
|
||||
"node": "./dist/composition.js",
|
||||
"import": "./src/composition.ts",
|
||||
"types": "./src/composition.ts"
|
||||
},
|
||||
"./sub-composition-validity": {
|
||||
"bun": "./src/subCompositionValidity.ts",
|
||||
"node": "./dist/subCompositionValidity.js",
|
||||
"import": "./src/subCompositionValidity.ts",
|
||||
"types": "./src/subCompositionValidity.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"
|
||||
},
|
||||
"./slideshow": {
|
||||
"import": "./dist/slideshow.js",
|
||||
"types": "./dist/slideshow.d.ts"
|
||||
},
|
||||
"./asset-paths": {
|
||||
"import": "./dist/assets.js",
|
||||
"types": "./dist/assets.d.ts"
|
||||
},
|
||||
"./composition": {
|
||||
"import": "./dist/composition.js",
|
||||
"types": "./dist/composition.d.ts"
|
||||
},
|
||||
"./sub-composition-validity": {
|
||||
"import": "./dist/subCompositionValidity.js",
|
||||
"types": "./dist/subCompositionValidity.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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
{
|
||||
"animations": [
|
||||
{
|
||||
"targetSelector": ".headline span",
|
||||
"method": "from",
|
||||
"position": 0.05,
|
||||
"properties": {
|
||||
"y": 46,
|
||||
"opacity": 0
|
||||
},
|
||||
"duration": 0.38,
|
||||
"ease": "back.out(1.35)",
|
||||
"extras": {
|
||||
"stagger": "__raw:0.055"
|
||||
},
|
||||
"resolvedStart": 0.05,
|
||||
"keyframes": {
|
||||
"format": "percentage",
|
||||
"keyframes": [
|
||||
{
|
||||
"percentage": 0,
|
||||
"properties": {
|
||||
"y": 46,
|
||||
"opacity": 0,
|
||||
"stagger": 0.055
|
||||
}
|
||||
},
|
||||
{
|
||||
"percentage": 100,
|
||||
"properties": {
|
||||
"y": 0,
|
||||
"opacity": 1,
|
||||
"stagger": 0.055
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"id": ".headline span-from-50"
|
||||
},
|
||||
{
|
||||
"targetSelector": ".headline .sub",
|
||||
"method": "from",
|
||||
"position": 0.2,
|
||||
"properties": {
|
||||
"y": 20,
|
||||
"opacity": 0
|
||||
},
|
||||
"duration": 0.28,
|
||||
"ease": "power3.out",
|
||||
"resolvedStart": 0.2,
|
||||
"id": ".headline .sub-from-200"
|
||||
},
|
||||
{
|
||||
"targetSelector": ".ambient-word",
|
||||
"method": "from",
|
||||
"position": 0.08,
|
||||
"properties": {
|
||||
"scale": 0.92,
|
||||
"opacity": 0
|
||||
},
|
||||
"duration": 0.5,
|
||||
"ease": "power3.out",
|
||||
"resolvedStart": 0.08,
|
||||
"id": ".ambient-word-from-80"
|
||||
},
|
||||
{
|
||||
"targetSelector": ".ambient-line",
|
||||
"method": "from",
|
||||
"position": 0.16,
|
||||
"properties": {
|
||||
"scaleX": 0,
|
||||
"opacity": 0
|
||||
},
|
||||
"duration": 0.42,
|
||||
"ease": "power3.out",
|
||||
"extras": {
|
||||
"stagger": "__raw:0.08"
|
||||
},
|
||||
"resolvedStart": 0.16,
|
||||
"keyframes": {
|
||||
"format": "percentage",
|
||||
"keyframes": [
|
||||
{
|
||||
"percentage": 0,
|
||||
"properties": {
|
||||
"scaleX": 0,
|
||||
"opacity": 0,
|
||||
"stagger": 0.08
|
||||
}
|
||||
},
|
||||
{
|
||||
"percentage": 100,
|
||||
"properties": {
|
||||
"scaleX": 1,
|
||||
"opacity": 1,
|
||||
"stagger": 0.08
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"id": ".ambient-line-from-160"
|
||||
}
|
||||
],
|
||||
"timelineVar": "tl",
|
||||
"preamble": "window.__timelines = window.__timelines || {};\ngsap.defaults({ force3D: true });\nconst tl = gsap.timeline({ paused: true, defaults: { duration: 0.45, ease: \"power3.out\" } });",
|
||||
"postamble": "window.__timelines[\"vpn-youtube-spot\"] = tl;"
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
|
||||
window.__timelines = window.__timelines || {};
|
||||
gsap.defaults({ force3D: true });
|
||||
const tl = gsap.timeline({ paused: true, defaults: { duration: 0.45, ease: "power3.out" } });
|
||||
tl.from(".headline span", { y: 46, opacity: 0, duration: 0.38, ease: "back.out(1.35)", stagger: 0.055 }, 0.05);
|
||||
tl.from(".ambient-word", { scale: 0.92, opacity: 0, duration: 0.5, ease: "power3.out" }, 0.08);
|
||||
tl.from(".ambient-line", { scaleX: 0, opacity: 0, duration: 0.42, ease: "power3.out", stagger: 0.08 }, 0.16);
|
||||
tl.from(".headline .sub", { y: 20, opacity: 0, duration: 0.28, ease: "power3.out" }, 0.2);
|
||||
window.__timelines["vpn-youtube-spot"] = tl;
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"animations": [
|
||||
{
|
||||
"targetSelector": "#hero",
|
||||
"method": "fromTo",
|
||||
"position": 0.1,
|
||||
"properties": {
|
||||
"x": 0,
|
||||
"opacity": 1
|
||||
},
|
||||
"fromProperties": {
|
||||
"x": -200,
|
||||
"opacity": 0
|
||||
},
|
||||
"duration": 0.6,
|
||||
"ease": "power3.out",
|
||||
"resolvedStart": 0.1,
|
||||
"id": "#hero-fromTo-100"
|
||||
},
|
||||
{
|
||||
"targetSelector": "#caption",
|
||||
"method": "fromTo",
|
||||
"position": 0.5,
|
||||
"properties": {
|
||||
"y": 0,
|
||||
"opacity": 1
|
||||
},
|
||||
"fromProperties": {
|
||||
"y": -30,
|
||||
"opacity": 0
|
||||
},
|
||||
"duration": 0.45,
|
||||
"resolvedStart": 0.5,
|
||||
"id": "#caption-fromTo-500"
|
||||
}
|
||||
],
|
||||
"timelineVar": "tl",
|
||||
"preamble": "var tl = gsap.timeline({ paused: true });",
|
||||
"postamble": "window.__timelines[\"hero-reveal\"] = tl;"
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.fromTo("#hero", { x: -200, opacity: 0 }, { x: 0, opacity: 1, duration: 0.6, ease: "power3.out" }, 0.1);
|
||||
tl.fromTo("#caption", { y: -30, opacity: 0 }, { y: 0, opacity: 1, duration: 0.45 }, 0.5);
|
||||
window.__timelines["hero-reveal"] = tl;
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"animations": [
|
||||
{
|
||||
"targetSelector": "#notification",
|
||||
"method": "to",
|
||||
"position": 0.2,
|
||||
"properties": {
|
||||
"x": 0,
|
||||
"opacity": 1
|
||||
},
|
||||
"fromProperties": {
|
||||
"x": 420,
|
||||
"opacity": 0
|
||||
},
|
||||
"duration": 0.5,
|
||||
"ease": "power3.out",
|
||||
"resolvedStart": 0.2,
|
||||
"id": "#notification-to-200"
|
||||
},
|
||||
{
|
||||
"targetSelector": "#notification",
|
||||
"method": "to",
|
||||
"position": 4.2,
|
||||
"properties": {
|
||||
"x": 420,
|
||||
"opacity": 0
|
||||
},
|
||||
"fromProperties": {
|
||||
"x": 0,
|
||||
"opacity": 1
|
||||
},
|
||||
"duration": 0.3,
|
||||
"ease": "power3.in",
|
||||
"resolvedStart": 4.2,
|
||||
"id": "#notification-to-4200"
|
||||
}
|
||||
],
|
||||
"timelineVar": "tl",
|
||||
"preamble": "var tl = gsap.timeline({ paused: true });",
|
||||
"postamble": "window.__timelines[\"macos-notification\"] = tl;"
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.to("#notification", { x: 0, opacity: 1, duration: 0.5, ease: "power3.out" }, 0.2);
|
||||
tl.to("#notification", { x: 420, opacity: 0, duration: 0.3, ease: "power3.in" }, 4.2);
|
||||
window.__timelines["macos-notification"] = tl;
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
{
|
||||
"animations": [
|
||||
{
|
||||
"targetSelector": "#card",
|
||||
"method": "to",
|
||||
"position": 0.1,
|
||||
"properties": {
|
||||
"y": 0,
|
||||
"opacity": 1
|
||||
},
|
||||
"fromProperties": {
|
||||
"y": 300,
|
||||
"opacity": 0
|
||||
},
|
||||
"duration": 0.5,
|
||||
"ease": "power3.out",
|
||||
"resolvedStart": 0.1,
|
||||
"id": "#card-to-100"
|
||||
},
|
||||
{
|
||||
"targetSelector": "#subscribe-btn",
|
||||
"method": "to",
|
||||
"position": 1,
|
||||
"properties": {
|
||||
"scale": 0.92
|
||||
},
|
||||
"duration": 0.15,
|
||||
"ease": "power2.out",
|
||||
"propertyGroup": "scale",
|
||||
"resolvedStart": 1,
|
||||
"id": "#subscribe-btn-to-1000-scale"
|
||||
},
|
||||
{
|
||||
"targetSelector": "#subscribe-btn",
|
||||
"method": "to",
|
||||
"position": 1.15,
|
||||
"properties": {
|
||||
"scale": 1
|
||||
},
|
||||
"fromProperties": {
|
||||
"scale": 0.92
|
||||
},
|
||||
"duration": 0.4,
|
||||
"ease": "elastic.out(1, 0.4)",
|
||||
"propertyGroup": "scale",
|
||||
"resolvedStart": 1.15,
|
||||
"id": "#subscribe-btn-to-1150-scale"
|
||||
},
|
||||
{
|
||||
"targetSelector": "#btn-subscribe",
|
||||
"method": "to",
|
||||
"position": 1.15,
|
||||
"properties": {
|
||||
"opacity": 0
|
||||
},
|
||||
"duration": 0.08,
|
||||
"ease": "none",
|
||||
"propertyGroup": "visual",
|
||||
"resolvedStart": 1.15,
|
||||
"id": "#btn-subscribe-to-1150-visual"
|
||||
},
|
||||
{
|
||||
"targetSelector": "#btn-subscribed",
|
||||
"method": "to",
|
||||
"position": 1.18,
|
||||
"properties": {
|
||||
"opacity": 1
|
||||
},
|
||||
"duration": 0.08,
|
||||
"ease": "none",
|
||||
"propertyGroup": "visual",
|
||||
"resolvedStart": 1.18,
|
||||
"id": "#btn-subscribed-to-1180-visual"
|
||||
},
|
||||
{
|
||||
"targetSelector": "#card",
|
||||
"method": "to",
|
||||
"position": 3.8,
|
||||
"properties": {
|
||||
"y": 300,
|
||||
"opacity": 0
|
||||
},
|
||||
"fromProperties": {
|
||||
"y": 0,
|
||||
"opacity": 1
|
||||
},
|
||||
"duration": 0.25,
|
||||
"ease": "power3.in",
|
||||
"resolvedStart": 3.8,
|
||||
"id": "#card-to-3800"
|
||||
}
|
||||
],
|
||||
"timelineVar": "tl",
|
||||
"preamble": "window.__timelines = window.__timelines || {};\nvar tl = gsap.timeline({ paused: true });",
|
||||
"postamble": "window.__timelines[\"yt-lower-third\"] = tl;"
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
|
||||
window.__timelines = window.__timelines || {};
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.to("#card", { y: 0, opacity: 1, duration: 0.5, ease: "power3.out" }, 0.1);
|
||||
tl.to("#subscribe-btn", { scale: 0.92, duration: 0.15, ease: "power2.out" }, 1);
|
||||
tl.to("#subscribe-btn", { scale: 1, duration: 0.4, ease: "elastic.out(1, 0.4)" }, 1.15);
|
||||
tl.to("#btn-subscribe", { opacity: 0, duration: 0.08, ease: "none" }, 1.15);
|
||||
tl.to("#btn-subscribed", { opacity: 1, duration: 0.08, ease: "none" }, 1.18);
|
||||
tl.to("#card", { y: 300, opacity: 0, duration: 0.25, ease: "power3.in" }, 3.8);
|
||||
window.__timelines["yt-lower-third"] = tl;
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Shared primitives for scanning and rewriting asset paths in HTML/CSS.
|
||||
*
|
||||
* Used by: rewriteSubCompPaths (core), collectExternalAssets (producer),
|
||||
* localizeExternalAssets (CLI publish).
|
||||
*/
|
||||
|
||||
import { isAbsolute, relative, resolve } from "node:path";
|
||||
|
||||
/**
|
||||
* Regex matching CSS `url(...)` references — captures the quote style and the
|
||||
* raw URL. The URL group is anchored to non-whitespace at both ends so the
|
||||
* surrounding `\s*` can never overlap it (avoids polynomial-ReDoS backtracking);
|
||||
* the captured value is whitespace-bounded already, matching the old behavior
|
||||
* after callers `.trim()` it.
|
||||
*/
|
||||
export const CSS_URL_RE = /\burl\(\s*(["']?)([^)"'\s](?:[^)"']*[^)"'\s])?)\1\s*\)/g;
|
||||
|
||||
/** Attributes that may contain relative asset paths. */
|
||||
export const PATH_ATTRS = ["src", "href"] as const;
|
||||
|
||||
/** Returns true for URLs/prefixes that should never be rewritten. */
|
||||
export function isNonRelativeUrl(val: string): boolean {
|
||||
return (
|
||||
!val ||
|
||||
val.startsWith("http://") ||
|
||||
val.startsWith("https://") ||
|
||||
val.startsWith("//") ||
|
||||
val.startsWith("data:") ||
|
||||
val.startsWith("#") ||
|
||||
val.startsWith("/")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-platform containment check: is `childPath` inside `parentPath`?
|
||||
* Equality counts as "inside".
|
||||
*/
|
||||
export function isPathInside(childPath: string, parentPath: string): boolean {
|
||||
const absChild = resolve(childPath);
|
||||
const absParent = resolve(parentPath);
|
||||
if (absChild === absParent) return true;
|
||||
const rel = relative(absParent, absChild);
|
||||
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// Node-only asset-path utilities (use node:path). Behind the
|
||||
// @hyperframes/parsers/asset-paths subpath so the main entry stays browser-safe.
|
||||
export * from "./assetPaths.js";
|
||||
export * from "./rewriteSubCompPaths.js";
|
||||
@@ -0,0 +1,18 @@
|
||||
// Pure, browser-safe composition primitives (data types, font aliases, URL
|
||||
// helper). Recast/linkedom-free, so browser consumers (e.g. the lint rule
|
||||
// engine via @hyperframes/lint/browser) can import these without pulling the
|
||||
// GSAP/HTML parser machinery from the main entry.
|
||||
export * from "./types.js";
|
||||
export {
|
||||
FONT_ALIAS_MAP,
|
||||
FONT_ALIAS_KEYS,
|
||||
CANONICAL_FONT_DISPLAY_NAMES,
|
||||
resolveAliasDisplayName,
|
||||
} from "./fontAliases.js";
|
||||
export { decodeUrlPathVariants } from "./utils/urlPath.js";
|
||||
export {
|
||||
parseCompositionVariables,
|
||||
isCompositionVariable,
|
||||
isScalarVariableValue,
|
||||
} from "./compositionVariables.js";
|
||||
export { scanVariableUsage, type VariableUsageScan } from "./variableUsage.js";
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Browser-safe parser for the `data-composition-variables` schema attribute.
|
||||
* Lives outside htmlParser.ts so browser consumers (SDK, Studio, lint) can
|
||||
* import it via `@hyperframes/parsers/composition` without pulling the
|
||||
* linkedom/Node HTML-parser machinery from the main entry.
|
||||
*/
|
||||
|
||||
import type { CompositionVariable, CompositionVariableType } from "./types.js";
|
||||
|
||||
/**
|
||||
* Required typeof for each variable type's `default`. For font the default is
|
||||
* the font-family name string; for image it is the fallback URL string —
|
||||
* extra metadata fields on both are optional and not validated here.
|
||||
*/
|
||||
const DEFAULT_TYPEOF: Record<CompositionVariableType, "string" | "number" | "boolean"> = {
|
||||
string: "string",
|
||||
number: "number",
|
||||
color: "string",
|
||||
boolean: "boolean",
|
||||
enum: "string",
|
||||
font: "string",
|
||||
image: "string",
|
||||
};
|
||||
|
||||
/**
|
||||
* Scalar variable values (string/number/boolean) are the ones that flow into
|
||||
* CSS custom props and text bindings; font/image values are object-shaped.
|
||||
* Shared so the SDK's CSS-compat writes, the runtime bindings, and Studio's
|
||||
* display logic can never disagree on what "scalar" means.
|
||||
*/
|
||||
export function isScalarVariableValue(value: unknown): value is string | number | boolean {
|
||||
return typeof value === "string" || typeof value === "number" || typeof value === "boolean";
|
||||
}
|
||||
|
||||
function isRecord(v: unknown): v is Record<string, unknown> {
|
||||
return typeof v === "object" && v !== null;
|
||||
}
|
||||
|
||||
function isVariableType(t: unknown): t is CompositionVariableType {
|
||||
return typeof t === "string" && t in DEFAULT_TYPEOF;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the value is a structurally valid variable declaration: id, label,
|
||||
* a known type, a default matching that type, and options[] for enums. The
|
||||
* same predicate parseCompositionVariables filters with — exported so writers
|
||||
* (SDK declaration ops, Studio forms) can validate before persisting.
|
||||
*/
|
||||
export function isCompositionVariable(v: unknown): v is CompositionVariable {
|
||||
if (!isRecord(v)) return false;
|
||||
if (typeof v.id !== "string" || typeof v.label !== "string") return false;
|
||||
if (!isVariableType(v.type)) return false;
|
||||
if (typeof v.default !== DEFAULT_TYPEOF[v.type]) return false;
|
||||
if (v.type === "enum" && !Array.isArray(v.options)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the typed variable declarations from an element's
|
||||
* `data-composition-variables` attribute. Malformed entries (wrong shape,
|
||||
* unknown type, default not matching the declared type) are dropped; an
|
||||
* absent attribute, invalid JSON, or a non-array payload yields `[]`.
|
||||
*/
|
||||
export function parseCompositionVariables(htmlEl: Element): CompositionVariable[] {
|
||||
const variablesAttr = htmlEl.getAttribute("data-composition-variables");
|
||||
if (!variablesAttr) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(variablesAttr);
|
||||
if (!Array.isArray(parsed)) {
|
||||
return [];
|
||||
}
|
||||
return parsed.filter(isCompositionVariable);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Single source of truth for the deterministic font alias map. Both the
|
||||
* producer's @font-face injector and the core lint rules import from here,
|
||||
* eliminating manual drift between the two.
|
||||
*
|
||||
* Keys are lowercase font family names. Values are canonical font slugs
|
||||
* matching CANONICAL_FONTS keys in the producer's deterministicFonts module.
|
||||
*/
|
||||
export const FONT_ALIAS_MAP = {
|
||||
// ── Canonical bundled fonts (self-referencing) ────────────────────────
|
||||
inter: "inter",
|
||||
montserrat: "montserrat",
|
||||
outfit: "outfit",
|
||||
nunito: "nunito",
|
||||
oswald: "oswald",
|
||||
"league gothic": "league-gothic",
|
||||
"archivo black": "archivo-black",
|
||||
"space mono": "space-mono",
|
||||
"ibm plex mono": "ibm-plex-mono",
|
||||
"jetbrains mono": "jetbrains-mono",
|
||||
"eb garamond": "eb-garamond",
|
||||
"playfair display": "playfair-display",
|
||||
"source code pro": "source-code-pro",
|
||||
"noto sans jp": "noto-sans-jp",
|
||||
roboto: "roboto",
|
||||
"open sans": "open-sans",
|
||||
lato: "lato",
|
||||
poppins: "poppins",
|
||||
|
||||
// ── Common aliases → nearest canonical ────────────────────────────────
|
||||
"helvetica neue": "inter",
|
||||
helvetica: "inter",
|
||||
arial: "inter",
|
||||
"helvetica bold": "inter",
|
||||
futura: "montserrat",
|
||||
"din alternate": "montserrat",
|
||||
"arial black": "montserrat",
|
||||
"bebas neue": "league-gothic",
|
||||
"courier new": "jetbrains-mono",
|
||||
courier: "jetbrains-mono",
|
||||
garamond: "eb-garamond",
|
||||
"noto sans japanese": "noto-sans-jp",
|
||||
"segoe ui": "roboto",
|
||||
|
||||
// ── macOS sans-serif system fonts → inter ─────────────────────────────
|
||||
"sf pro": "inter",
|
||||
"sf pro display": "inter",
|
||||
"sf pro text": "inter",
|
||||
"sf pro rounded": "inter",
|
||||
avenir: "inter",
|
||||
"avenir next": "inter",
|
||||
"lucida grande": "inter",
|
||||
geneva: "inter",
|
||||
optima: "inter",
|
||||
|
||||
// ── Windows sans-serif system fonts → inter ───────────────────────────
|
||||
verdana: "inter",
|
||||
tahoma: "inter",
|
||||
"trebuchet ms": "inter",
|
||||
calibri: "inter",
|
||||
candara: "inter",
|
||||
corbel: "inter",
|
||||
"lucida sans": "inter",
|
||||
"lucida sans unicode": "inter",
|
||||
|
||||
// ── Linux sans-serif system fonts → inter ─────────────────────────────
|
||||
"noto sans": "inter",
|
||||
"dejavu sans": "inter",
|
||||
"liberation sans": "inter",
|
||||
|
||||
// ── Monospace system fonts → jetbrains-mono ───────────────────────────
|
||||
"sf mono": "jetbrains-mono",
|
||||
menlo: "jetbrains-mono",
|
||||
monaco: "jetbrains-mono",
|
||||
consolas: "jetbrains-mono",
|
||||
"lucida console": "jetbrains-mono",
|
||||
"lucida sans typewriter": "jetbrains-mono",
|
||||
"andale mono": "jetbrains-mono",
|
||||
"dejavu sans mono": "jetbrains-mono",
|
||||
"liberation mono": "jetbrains-mono",
|
||||
|
||||
// ── Serif system fonts → eb-garamond ──────────────────────────────────
|
||||
georgia: "eb-garamond",
|
||||
palatino: "eb-garamond",
|
||||
"palatino linotype": "eb-garamond",
|
||||
"book antiqua": "eb-garamond",
|
||||
cambria: "eb-garamond",
|
||||
times: "eb-garamond",
|
||||
"times new roman": "eb-garamond",
|
||||
"dejavu serif": "eb-garamond",
|
||||
"liberation serif": "eb-garamond",
|
||||
} satisfies Readonly<Record<string, string>>;
|
||||
|
||||
export const FONT_ALIAS_KEYS: ReadonlySet<string> = new Set(Object.keys(FONT_ALIAS_MAP));
|
||||
|
||||
/**
|
||||
* Human-readable display names for canonical font slugs. Used by the lint
|
||||
* rule to tell authors what their aliased font will render as.
|
||||
*/
|
||||
export const CANONICAL_FONT_DISPLAY_NAMES: Readonly<Record<string, string>> = {
|
||||
inter: "Inter",
|
||||
montserrat: "Montserrat",
|
||||
outfit: "Outfit",
|
||||
nunito: "Nunito",
|
||||
oswald: "Oswald",
|
||||
"league-gothic": "League Gothic",
|
||||
"archivo-black": "Archivo Black",
|
||||
"space-mono": "Space Mono",
|
||||
"ibm-plex-mono": "IBM Plex Mono",
|
||||
"jetbrains-mono": "JetBrains Mono",
|
||||
"eb-garamond": "EB Garamond",
|
||||
"playfair-display": "Playfair Display",
|
||||
"source-code-pro": "Source Code Pro",
|
||||
"noto-sans-jp": "Noto Sans JP",
|
||||
roboto: "Roboto",
|
||||
"open-sans": "Open Sans",
|
||||
lato: "Lato",
|
||||
poppins: "Poppins",
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve a font alias to its canonical display name, or undefined if the
|
||||
* alias is not in the map.
|
||||
*/
|
||||
export function resolveAliasDisplayName(alias: string): string | undefined {
|
||||
const slug = (FONT_ALIAS_MAP as Record<string, string>)[alias.toLowerCase()];
|
||||
if (!slug) return undefined;
|
||||
return CANONICAL_FONT_DISPLAY_NAMES[slug];
|
||||
}
|
||||
@@ -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<PropertyGroupName, ReadonlySet<string>> = {
|
||||
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<string>(),
|
||||
};
|
||||
|
||||
const PROP_TO_GROUP = new Map<string, PropertyGroupName>();
|
||||
for (const [group, props] of Object.entries(PROPERTY_GROUPS) as [
|
||||
PropertyGroupName,
|
||||
ReadonlySet<string>,
|
||||
][]) {
|
||||
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<string, unknown>,
|
||||
): PropertyGroupName | undefined {
|
||||
const groups = new Set<PropertyGroupName>();
|
||||
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)",
|
||||
];
|
||||
@@ -0,0 +1,171 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parse } from "acorn";
|
||||
import { simple } from "acorn-walk";
|
||||
import {
|
||||
cloneNode,
|
||||
inlineComputedTimelines,
|
||||
numericLiteral,
|
||||
readProvenance,
|
||||
substituteParams,
|
||||
tagProvenance,
|
||||
} from "./gsapInline.js";
|
||||
|
||||
// Parse a single expression / statement to its ESTree node.
|
||||
const expr = (code: string): any =>
|
||||
(parse(code, { ecmaVersion: "latest" }).body[0] as any).expression;
|
||||
const stmt = (code: string): any => parse(code, { ecmaVersion: "latest" }).body[0] as any;
|
||||
const bind = (entries: Record<string, string>): Map<string, any> =>
|
||||
new Map(Object.entries(entries).map(([k, v]) => [k, expr(v)]));
|
||||
|
||||
describe("substituteParams", () => {
|
||||
it("substitutes a scalar param inside a binary expression", () => {
|
||||
const out = substituteParams(cloneNode(expr("at + 0.15")), bind({ at: "1.0" }));
|
||||
expect(out.type).toBe("BinaryExpression");
|
||||
expect(out.left).toMatchObject({ type: "Literal", value: 1 });
|
||||
expect(out.right).toMatchObject({ type: "Literal", value: 0.15 });
|
||||
});
|
||||
|
||||
it("substitutes an array param used as a value", () => {
|
||||
const out = substituteParams(cloneNode(expr("({ path })")), bind({ path: "[{x:0},{x:1}]" }));
|
||||
expect(out.properties[0].value.type).toBe("ArrayExpression");
|
||||
expect(out.properties[0].value.elements).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("does not substitute a name shadowed by an inner const", () => {
|
||||
const out = substituteParams(
|
||||
cloneNode(stmt("function f(){ const at = 5; return at; }")),
|
||||
bind({ at: "1.0" }),
|
||||
);
|
||||
const ret = out.body.body[1].argument;
|
||||
expect(ret).toMatchObject({ type: "Identifier", name: "at" });
|
||||
});
|
||||
|
||||
it("does not substitute a name shadowed by a nested function param", () => {
|
||||
const out = substituteParams(cloneNode(expr("(at) => at")), bind({ at: "1.0" }));
|
||||
expect(out.body).toMatchObject({ type: "Identifier", name: "at" });
|
||||
});
|
||||
|
||||
it("does not substitute object keys or non-computed member properties", () => {
|
||||
const obj = substituteParams(cloneNode(expr("({ at: 1 })")), bind({ at: "9" }));
|
||||
expect(obj.properties[0].key).toMatchObject({ type: "Identifier", name: "at" });
|
||||
const mem = substituteParams(cloneNode(expr("obj.at")), bind({ at: "9" }));
|
||||
expect(mem.property).toMatchObject({ type: "Identifier", name: "at" });
|
||||
});
|
||||
|
||||
it("does substitute a computed member property", () => {
|
||||
const out = substituteParams(cloneNode(expr("obj[at]")), bind({ at: "0" }));
|
||||
expect(out.property).toMatchObject({ type: "Literal", value: 0 });
|
||||
});
|
||||
|
||||
it("does not mutate the input clone's source", () => {
|
||||
const original = expr("at + 0.15");
|
||||
substituteParams(cloneNode(original), bind({ at: "1.0" }));
|
||||
expect(original.left).toMatchObject({ type: "Identifier", name: "at" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("provenance + numericLiteral", () => {
|
||||
it("round-trips a provenance tag", () => {
|
||||
const node = expr("tl.to('#x', {}, 1)");
|
||||
tagProvenance(node, { kind: "helper", fn: "addCycle", callSite: 2 });
|
||||
expect(readProvenance(node)).toEqual({ kind: "helper", fn: "addCycle", callSite: 2 });
|
||||
});
|
||||
|
||||
it("builds a resolvable numeric literal", () => {
|
||||
expect(numericLiteral(3.5)).toMatchObject({ type: "Literal", value: 3.5 });
|
||||
});
|
||||
});
|
||||
|
||||
// Resolve only direct literals — enough to drive loop-bound resolution in tests.
|
||||
const litResolve = (n: any): any => (n?.type === "Literal" ? n.value : undefined);
|
||||
|
||||
// The tl.* method of a direct `tl.method(...)` call (test scripts don't chain), or null.
|
||||
function tlMethod(call: any, tl: string): string | null {
|
||||
if (call.callee?.object?.name !== tl) return null;
|
||||
const m = call.callee?.property?.name;
|
||||
return ["set", "to", "from", "fromTo"].includes(m) ? m : null;
|
||||
}
|
||||
|
||||
function run(code: string, tl = "tl"): { ast: any; tweens: Array<{ prov: any; pos: any }> } {
|
||||
const ast: any = parse(code, { ecmaVersion: "latest" });
|
||||
inlineComputedTimelines(ast, tl, litResolve);
|
||||
const tweens: Array<{ prov: any; pos: any }> = [];
|
||||
simple(ast, {
|
||||
CallExpression(n: any) {
|
||||
const m = tlMethod(n, tl);
|
||||
if (m) tweens.push({ prov: readProvenance(n), pos: n.arguments?.[m === "fromTo" ? 3 : 2] });
|
||||
},
|
||||
});
|
||||
return { ast, tweens };
|
||||
}
|
||||
|
||||
const kinds = (t: Array<{ prov: any }>): any[] => t.map((x) => x.prov?.kind);
|
||||
const sites = (t: Array<{ prov: any }>): any[] => t.map((x) => x.prov?.callSite);
|
||||
const iters = (t: Array<{ prov: any }>): any[] => t.map((x) => x.prov?.iteration);
|
||||
|
||||
describe("inlineComputedTimelines — helpers", () => {
|
||||
it("expands a helper called N times, substituting positions per call", () => {
|
||||
const { tweens } = run(`const tl=gsap.timeline();
|
||||
function addCycle(at){ tl.to("#p", {}, at + 0.3); }
|
||||
addCycle(1.0); addCycle(3.6);`);
|
||||
expect(tweens).toHaveLength(2);
|
||||
expect(kinds(tweens)).toEqual(["helper", "helper"]);
|
||||
expect(sites(tweens)).toEqual([1, 2]);
|
||||
expect(tweens[0]!.pos).toMatchObject({ type: "BinaryExpression", left: { value: 1 } });
|
||||
expect(tweens[1]!.pos).toMatchObject({ left: { value: 3.6 } });
|
||||
});
|
||||
|
||||
it("expands every tween in a multi-tween helper body", () => {
|
||||
const { tweens } = run(`const tl=gsap.timeline();
|
||||
function addCycle(at){ tl.to("#a", {}, at); tl.to("#b", {}, at + 1); }
|
||||
addCycle(1); addCycle(5);`);
|
||||
expect(tweens).toHaveLength(4);
|
||||
expect(sites(tweens)).toEqual([1, 1, 2, 2]);
|
||||
});
|
||||
|
||||
it("inlines nested helpers to a fixpoint", () => {
|
||||
const { tweens } = run(`const tl=gsap.timeline();
|
||||
function inner(t){ tl.to("#x", {}, t); }
|
||||
function outer(at){ inner(at); }
|
||||
outer(5);`);
|
||||
expect(tweens).toHaveLength(1);
|
||||
expect(tweens[0]!.prov?.fn).toBe("inner");
|
||||
expect(tweens[0]!.pos).toMatchObject({ type: "Literal", value: 5 });
|
||||
});
|
||||
|
||||
it("caps runaway recursion instead of hanging", () => {
|
||||
const { tweens } = run(`const tl=gsap.timeline();
|
||||
function r(n){ tl.to("#x", {}, n); r(n); }
|
||||
r(0);`);
|
||||
expect(tweens.length).toBeGreaterThan(0);
|
||||
expect(tweens.length).toBeLessThanOrEqual(8);
|
||||
});
|
||||
|
||||
it("leaves a non-timeline helper untouched", () => {
|
||||
const { ast, tweens } = run(`function bez(t){ return t * 2; }
|
||||
const tl=gsap.timeline();
|
||||
tl.to("#x", {}, bez(1));`);
|
||||
expect(
|
||||
ast.body.some((s: any) => s.type === "FunctionDeclaration" && s.id?.name === "bez"),
|
||||
).toBe(true);
|
||||
expect(tweens).toHaveLength(1);
|
||||
expect(tweens[0]!.prov).toBeUndefined(); // literal tween, no provenance tag
|
||||
});
|
||||
});
|
||||
|
||||
describe("inlineComputedTimelines — loops", () => {
|
||||
it("unrolls a for-loop with literal bounds", () => {
|
||||
const { tweens } = run(`const tl=gsap.timeline();
|
||||
for (let i = 0; i < 3; i++) { tl.to("#x", {}, i * 0.5); }`);
|
||||
expect(tweens).toHaveLength(3);
|
||||
expect(iters(tweens)).toEqual([0, 1, 2]);
|
||||
expect(tweens.map((t) => t.pos.left.value)).toEqual([0, 1, 2]);
|
||||
});
|
||||
|
||||
it("unrolls forEach over an inline array", () => {
|
||||
const { tweens } = run(`const tl=gsap.timeline();
|
||||
[{t:1},{t:2}].forEach((d) => { tl.to("#x", {}, d.t); });`);
|
||||
expect(tweens).toHaveLength(2);
|
||||
expect(kinds(tweens)).toEqual(["loop", "loop"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,584 @@
|
||||
/**
|
||||
* Static evaluation for computed GSAP timelines (browser-safe, acorn/ESTree).
|
||||
*
|
||||
* The read parser resolves only literals and top-level consts, so timelines
|
||||
* built by a helper called N times or by a bounded loop collapse to position 0.
|
||||
* This module expands those constructs into a synthetic analysis AST: each
|
||||
* helper invocation and each loop iteration becomes its own concrete set of
|
||||
* `tl.*` calls, with parameters/loop-vars substituted by the call's argument
|
||||
* (or element/index) AST nodes — after which the existing parse pipeline
|
||||
* resolves positions and `motionPath` arcs unchanged.
|
||||
*
|
||||
* Substituted nodes keep their original source offsets, so downstream
|
||||
* source-slicing (raw extras, keyframes) stays correct. The substitution
|
||||
* primitives never mutate their input; `inlineComputedTimelines` rewrites the
|
||||
* Program body of the freshly-parsed AST it is handed (owned by the caller).
|
||||
*/
|
||||
import type { GsapProvenance } from "./gsapSerialize.js";
|
||||
|
||||
// acorn ESTree nodes are structurally untyped; mirror gsapParserAcorn.ts.
|
||||
type Node = any;
|
||||
|
||||
/** Node keys that are metadata, not child AST to traverse/substitute. */
|
||||
const SKIP_KEYS = new Set(["type", "start", "end", "loc", "range", "__hfProvenance", "__hfOrder"]);
|
||||
|
||||
const FUNCTION_TYPES = new Set([
|
||||
"ArrowFunctionExpression",
|
||||
"FunctionExpression",
|
||||
"FunctionDeclaration",
|
||||
]);
|
||||
const GSAP_METHODS = new Set(["set", "to", "from", "fromTo"]);
|
||||
|
||||
// Bounds on synthetic expansion (recursion + iteration runaway guards).
|
||||
const MAX_DEPTH = 8;
|
||||
const MAX_ITERS = 512;
|
||||
|
||||
function isFunctionNode(node: Node): boolean {
|
||||
return !!node && FUNCTION_TYPES.has(node.type);
|
||||
}
|
||||
|
||||
function isNode(x: Node): boolean {
|
||||
return !!x && typeof x === "object" && typeof x.type === "string";
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply `fn` to each child AST node, writing back its return value. Skips
|
||||
* metadata keys and key/member slots that must not be treated as values.
|
||||
* The one place array-vs-single child traversal lives, so walkers stay flat.
|
||||
*/
|
||||
function transformChildren(node: Node, fn: (child: Node) => Node): void {
|
||||
for (const key of Object.keys(node)) {
|
||||
if (SKIP_KEYS.has(key) || isNonValueIdentifierSlot(node, key)) continue;
|
||||
const child = node[key];
|
||||
if (Array.isArray(child)) {
|
||||
for (let i = 0; i < child.length; i++) child[i] = fn(child[i]);
|
||||
} else {
|
||||
node[key] = fn(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Deep structural clone preserving `start`/`end`/`loc` (needed for source slicing). */
|
||||
export function cloneNode<T extends Node>(node: T): T {
|
||||
return structuredClone(node);
|
||||
}
|
||||
|
||||
// ponytail: Identifier + default + rest only. Destructured bindings (`{x}`, `[x]`)
|
||||
// aren't inlined (U2 inlines Identifier-param helpers / loop vars only), so a
|
||||
// destructuring shadow is a double-rare miss that just falls back. Add the
|
||||
// pattern cases here if that ever bites.
|
||||
function collectPatternNames(pattern: Node, out: Set<string>): void {
|
||||
if (pattern?.type === "Identifier") out.add(pattern.name);
|
||||
else if (pattern?.type === "AssignmentPattern") collectPatternNames(pattern.left, out);
|
||||
else if (pattern?.type === "RestElement") collectPatternNames(pattern.argument, out);
|
||||
}
|
||||
|
||||
/** Every identifier name bound anywhere inside the subtree (fn params, declared vars, catch params). */
|
||||
function collectBoundNames(root: Node): Set<string> {
|
||||
const names = new Set<string>();
|
||||
const visit = (node: Node): Node => {
|
||||
if (!isNode(node)) return node;
|
||||
if (isFunctionNode(node)) for (const p of node.params ?? []) collectPatternNames(p, names);
|
||||
else if (node.type === "VariableDeclarator") collectPatternNames(node.id, names);
|
||||
else if (node.type === "CatchClause") collectPatternNames(node.param, names);
|
||||
transformChildren(node, visit);
|
||||
return node;
|
||||
};
|
||||
visit(root);
|
||||
return names;
|
||||
}
|
||||
|
||||
/** A child in key/property position that must not be treated as a value identifier. */
|
||||
function isNonValueIdentifierSlot(node: Node, key: string): boolean {
|
||||
if (node.computed) return false;
|
||||
return (
|
||||
(node.type === "MemberExpression" && key === "property") ||
|
||||
(node.type === "Property" && key === "key")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Substitute bound identifiers in an already-cloned subtree, returning the
|
||||
* (possibly replaced) root. Names shadowed anywhere inside (nested function
|
||||
* params, declared vars) are dropped up front rather than tracked per scope —
|
||||
* worst case we under-substitute and the caller falls back to current behavior.
|
||||
* Never substitutes identifiers in key/member positions. Mutates the passed
|
||||
* clone in place — callers pass `cloneNode(...)`.
|
||||
*/
|
||||
export function substituteParams(node: Node, bindings: ReadonlyMap<string, Node>): Node {
|
||||
const shadowed = collectBoundNames(node);
|
||||
let effective = bindings;
|
||||
if (shadowed.size > 0) {
|
||||
effective = new Map(bindings);
|
||||
for (const name of shadowed) (effective as Map<string, Node>).delete(name);
|
||||
}
|
||||
if (effective.size === 0) return node;
|
||||
return replace(node, effective);
|
||||
}
|
||||
|
||||
function replace(node: Node, bindings: ReadonlyMap<string, Node>): Node {
|
||||
if (!isNode(node)) return node;
|
||||
if (node.type === "Identifier" && bindings.has(node.name)) {
|
||||
return cloneNode(bindings.get(node.name));
|
||||
}
|
||||
transformChildren(node, (child) => replace(child, bindings));
|
||||
return node;
|
||||
}
|
||||
|
||||
/** Tag a node (typically a `tl.*` CallExpression) with its construction provenance. */
|
||||
export function tagProvenance(node: Node, provenance: GsapProvenance): Node {
|
||||
if (node && typeof node === "object") node.__hfProvenance = provenance;
|
||||
return node;
|
||||
}
|
||||
|
||||
/** Read a provenance tag previously set by `tagProvenance`, if any. */
|
||||
export function readProvenance(node: Node): GsapProvenance | undefined {
|
||||
return node?.__hfProvenance;
|
||||
}
|
||||
|
||||
/** Synthesize a numeric `Literal` node (for loop indices, which have no source node). */
|
||||
export function numericLiteral(value: number): Node {
|
||||
return { type: "Literal", value, raw: String(value) };
|
||||
}
|
||||
|
||||
// ── Expansion engine (U2) ─────────────────────────────────────────────────────
|
||||
|
||||
/** Resolve an expression to a literal value (top-level consts in scope, arithmetic). */
|
||||
type LiteralResolver = (node: Node) => number | string | boolean | undefined;
|
||||
|
||||
interface ExpandCtx {
|
||||
helpers: Map<string, Node>;
|
||||
timelineVar: string;
|
||||
resolve: LiteralResolver;
|
||||
depth: number;
|
||||
/** Mutable source-order counter for provenance call-site ordinals. */
|
||||
site: { n: number };
|
||||
/** Mutable counter stamping expansion order onto tweens (clones share source loc). */
|
||||
order: { n: number };
|
||||
}
|
||||
|
||||
function walkNodes(node: Node, fn: (n: Node) => void): void {
|
||||
if (!isNode(node)) return;
|
||||
fn(node);
|
||||
for (const key of Object.keys(node)) {
|
||||
if (SKIP_KEYS.has(key)) continue;
|
||||
const child = node[key];
|
||||
if (Array.isArray(child)) for (const c of child) walkNodes(c, fn);
|
||||
else walkNodes(child, fn);
|
||||
}
|
||||
}
|
||||
|
||||
/** The identifier a (possibly chained) call's member expression is rooted at. */
|
||||
function timelineRootName(call: Node): string | null {
|
||||
let obj = call.callee?.object;
|
||||
while (obj?.type === "CallExpression") obj = obj.callee?.object;
|
||||
return obj?.type === "Identifier" ? obj.name : null;
|
||||
}
|
||||
|
||||
function isTimelineRooted(call: Node, timelineVar: string): boolean {
|
||||
if (timelineRootName(call) !== timelineVar) return false;
|
||||
return (
|
||||
call.callee?.property?.type === "Identifier" && GSAP_METHODS.has(call.callee.property.name)
|
||||
);
|
||||
}
|
||||
|
||||
function containsTimelineCall(node: Node, timelineVar: string): boolean {
|
||||
let found = false;
|
||||
walkNodes(node, (n) => {
|
||||
if (n.type === "CallExpression" && isTimelineRooted(n, timelineVar)) found = true;
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
function rangeOf(node: Node): [number, number] | undefined {
|
||||
return typeof node.start === "number" && typeof node.end === "number"
|
||||
? [node.start, node.end]
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/** Plain identifier params + block body (shape we can inline). Timeline content checked separately. */
|
||||
function isShapeEligible(fn: Node): boolean {
|
||||
return (
|
||||
isFunctionNode(fn) &&
|
||||
fn.body?.type === "BlockStatement" &&
|
||||
!(fn.params ?? []).some((p: Node) => p.type !== "Identifier")
|
||||
);
|
||||
}
|
||||
|
||||
/** True if the subtree calls any function named in `names`. */
|
||||
function callsAny(node: Node, names: Set<string>): boolean {
|
||||
let hit = false;
|
||||
walkNodes(node, (n) => {
|
||||
if (
|
||||
n.type === "CallExpression" &&
|
||||
n.callee?.type === "Identifier" &&
|
||||
names.has(n.callee.name)
|
||||
) {
|
||||
hit = true;
|
||||
}
|
||||
});
|
||||
return hit;
|
||||
}
|
||||
|
||||
/** `[name, fnNode]` if a single-declarator `const f = fn` is an inlinable-shaped helper. */
|
||||
function varDeclHelper(stmt: Node): [string, Node] | null {
|
||||
if (stmt.declarations?.length !== 1) return null;
|
||||
const d = stmt.declarations[0];
|
||||
return d.id?.type === "Identifier" && isShapeEligible(d.init) ? [d.id.name, d.init] : null;
|
||||
}
|
||||
|
||||
/** `[name, fnNode]` if `stmt` declares an inlinable-shaped helper, else null. */
|
||||
function helperFromStatement(stmt: Node): [string, Node] | null {
|
||||
if (stmt.type === "FunctionDeclaration") {
|
||||
return stmt.id && isShapeEligible(stmt) ? [stmt.id.name, stmt] : null;
|
||||
}
|
||||
if (stmt.type === "VariableDeclaration") return varDeclHelper(stmt);
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Top-level functions whose shape we can inline (Identifier params + block body). */
|
||||
function gatherHelperCandidates(program: Node): Map<string, Node> {
|
||||
const candidates = new Map<string, Node>();
|
||||
for (const stmt of program.body ?? []) {
|
||||
const helper = helperFromStatement(stmt);
|
||||
if (helper) candidates.set(helper[0], helper[1]);
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
/** Names that build the timeline directly or by calling another builder (transitive closure). */
|
||||
function timelineBuildingNames(candidates: Map<string, Node>, timelineVar: string): Set<string> {
|
||||
const building = new Set<string>();
|
||||
for (const [name, fn] of candidates) {
|
||||
if (containsTimelineCall(fn.body, timelineVar)) building.add(name);
|
||||
}
|
||||
for (let changed = true; changed; ) {
|
||||
changed = false;
|
||||
for (const [name, fn] of candidates) {
|
||||
if (!building.has(name) && callsAny(fn.body, building)) {
|
||||
building.add(name);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return building;
|
||||
}
|
||||
|
||||
function bump(counts: Map<string, number>, key: string): void {
|
||||
counts.set(key, (counts.get(key) ?? 0) + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep only candidates safe to drop: every reference to the name is its
|
||||
* declaration or a statement-level call. (1 decl id + 1 callee id per
|
||||
* statement-level call ⇒ total occurrences with no stray uses.)
|
||||
*/
|
||||
function safelyDroppable(program: Node, candidates: Map<string, Node>): Map<string, Node> {
|
||||
const names = new Set(candidates.keys());
|
||||
const totalIds = new Map<string, number>();
|
||||
const stmtCalls = new Map<string, number>();
|
||||
walkNodes(program, (n) => {
|
||||
if (n.type === "Identifier" && names.has(n.name)) bump(totalIds, n.name);
|
||||
const e = n.type === "ExpressionStatement" ? n.expression : undefined;
|
||||
if (
|
||||
e?.type === "CallExpression" &&
|
||||
e.callee?.type === "Identifier" &&
|
||||
names.has(e.callee.name)
|
||||
) {
|
||||
bump(stmtCalls, e.callee.name);
|
||||
}
|
||||
});
|
||||
const safe = new Map<string, Node>();
|
||||
for (const [name, fn] of candidates) {
|
||||
if ((totalIds.get(name) ?? 0) === 1 + (stmtCalls.get(name) ?? 0)) safe.set(name, fn);
|
||||
}
|
||||
return safe;
|
||||
}
|
||||
|
||||
/** Top-level timeline-building helpers that are safe to inline-and-drop. */
|
||||
function collectInlinableHelpers(program: Node, timelineVar: string): Map<string, Node> {
|
||||
const candidates = gatherHelperCandidates(program);
|
||||
if (candidates.size === 0) return candidates;
|
||||
const building = timelineBuildingNames(candidates, timelineVar);
|
||||
for (const name of [...candidates.keys()]) if (!building.has(name)) candidates.delete(name);
|
||||
if (candidates.size === 0) return candidates;
|
||||
return safelyDroppable(program, candidates);
|
||||
}
|
||||
|
||||
function isHelperDecl(stmt: Node, helpers: Map<string, Node>): boolean {
|
||||
if (stmt.type === "FunctionDeclaration") return !!stmt.id && helpers.get(stmt.id.name) === stmt;
|
||||
if (stmt.type === "VariableDeclaration" && stmt.declarations?.length === 1) {
|
||||
const d = stmt.declarations[0];
|
||||
return d.id?.type === "Identifier" && helpers.get(d.id.name) === d.init;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function bodyStatements(node: Node): Node[] {
|
||||
if (node?.type === "BlockStatement") return node.body ?? [];
|
||||
return node ? [{ type: "ExpressionStatement", expression: node }] : [];
|
||||
}
|
||||
|
||||
/** Tag this body's direct timeline tweens with provenance + a monotonic expansion-order stamp. */
|
||||
function tagTimelineCalls(stmts: Node[], prov: GsapProvenance, ctx: ExpandCtx): void {
|
||||
for (const stmt of stmts) {
|
||||
walkNodes(stmt, (n) => {
|
||||
if (n.type === "CallExpression" && isTimelineRooted(n, ctx.timelineVar)) {
|
||||
tagProvenance(n, { ...prov });
|
||||
n.__hfOrder = ctx.order.n++;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Clone a body as one scope, substitute the bindings, tag provenance, recurse. */
|
||||
function expandBody(
|
||||
bodyStmts: Node[],
|
||||
bindings: Map<string, Node>,
|
||||
prov: GsapProvenance,
|
||||
ctx: ExpandCtx,
|
||||
): Node[] {
|
||||
const block = substituteParams(cloneNode({ type: "BlockStatement", body: bodyStmts }), bindings);
|
||||
tagTimelineCalls(block.body, prov, ctx);
|
||||
return expandStatements(block.body, { ...ctx, depth: ctx.depth + 1 });
|
||||
}
|
||||
|
||||
function inlineHelper(call: Node, ctx: ExpandCtx): Node[] {
|
||||
const fn = ctx.helpers.get(call.callee.name);
|
||||
const bindings = new Map<string, Node>();
|
||||
(fn.params ?? []).forEach((p: Node, i: number) => {
|
||||
const arg = call.arguments?.[i];
|
||||
if (arg) bindings.set(p.name, arg);
|
||||
});
|
||||
const prov: GsapProvenance = {
|
||||
kind: "helper",
|
||||
fn: call.callee.name,
|
||||
callSite: ++ctx.site.n,
|
||||
sourceRange: rangeOf(call),
|
||||
};
|
||||
return expandBody(fn.body.body, bindings, prov, ctx);
|
||||
}
|
||||
|
||||
function assignStep(update: Node, resolve: LiteralResolver): number | undefined {
|
||||
if (update.operator === "+=") return asNum(resolve(update.right));
|
||||
if (update.operator === "-=") {
|
||||
const s = asNum(resolve(update.right));
|
||||
return s === undefined ? undefined : -s;
|
||||
}
|
||||
// `i = i + S` — the step is the right operand of the addition.
|
||||
if (update.operator === "=" && update.right?.type === "BinaryExpression") {
|
||||
return asNum(resolve(update.right.right));
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** The loop variable a `for` update clause mutates (`i++` or `i += S`), or null. */
|
||||
function updatedVarName(update: Node): string | null {
|
||||
if (update?.type === "UpdateExpression") return update.argument?.name ?? null;
|
||||
if (update?.type === "AssignmentExpression") return update.left?.name ?? null;
|
||||
return null;
|
||||
}
|
||||
|
||||
function loopStep(update: Node, varName: string, resolve: LiteralResolver): number | undefined {
|
||||
if (updatedVarName(update) !== varName) return undefined;
|
||||
if (update.type === "UpdateExpression") return update.operator === "++" ? 1 : -1;
|
||||
return assignStep(update, resolve);
|
||||
}
|
||||
|
||||
function asNum(v: unknown): number | undefined {
|
||||
return typeof v === "number" && Number.isFinite(v) ? v : undefined;
|
||||
}
|
||||
|
||||
function loopSatisfied(op: string, x: number, end: number): boolean {
|
||||
if (op === "<") return x < end;
|
||||
if (op === "<=") return x <= end;
|
||||
if (op === ">") return x > end;
|
||||
if (op === ">=") return x >= end;
|
||||
return false;
|
||||
}
|
||||
|
||||
interface ForHeader {
|
||||
v: string;
|
||||
start: number;
|
||||
end: number;
|
||||
op: string;
|
||||
step: number;
|
||||
}
|
||||
|
||||
/** The single `let v = <init>` of a for-loop init clause, or null. */
|
||||
function forInitVar(init: Node): { name: string; initExpr: Node } | null {
|
||||
if (init?.type !== "VariableDeclaration" || init.declarations?.length !== 1) return null;
|
||||
const d = init.declarations[0];
|
||||
return d.id?.type === "Identifier" ? { name: d.id.name, initExpr: d.init } : null;
|
||||
}
|
||||
|
||||
/** Parse `for (let v = A; v <op> B; v += S)` into resolved bounds, or null if not statically bounded. */
|
||||
function parseForHeader(stmt: Node, resolve: LiteralResolver): ForHeader | null {
|
||||
const iv = forInitVar(stmt.init);
|
||||
const test = stmt.test;
|
||||
if (!iv || test?.type !== "BinaryExpression" || test.left?.name !== iv.name) return null;
|
||||
const start = asNum(resolve(iv.initExpr));
|
||||
const end = asNum(resolve(test.right));
|
||||
const step = loopStep(stmt.update, iv.name, resolve);
|
||||
if (start === undefined || end === undefined || !step) return null;
|
||||
return { v: iv.name, start, end, op: test.operator, step };
|
||||
}
|
||||
|
||||
function unrollFor(stmt: Node, ctx: ExpandCtx): Node[] | null {
|
||||
const h = parseForHeader(stmt, ctx.resolve);
|
||||
if (!h) return null;
|
||||
const body = bodyStatements(stmt.body);
|
||||
const out: Node[] = [];
|
||||
const site = ++ctx.site.n;
|
||||
let iteration = 0;
|
||||
for (let x = h.start; loopSatisfied(h.op, x, h.end); x += h.step) {
|
||||
if (iteration >= MAX_ITERS) return null;
|
||||
const prov: GsapProvenance = {
|
||||
kind: "loop",
|
||||
callSite: site,
|
||||
iteration,
|
||||
sourceRange: rangeOf(stmt),
|
||||
};
|
||||
out.push(...expandBody(body, new Map([[h.v, numericLiteral(x)]]), prov, ctx));
|
||||
iteration++;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function forOfVarName(left: Node): string | null {
|
||||
if (left?.type === "VariableDeclaration") {
|
||||
const id = left.declarations?.[0]?.id;
|
||||
return id?.type === "Identifier" ? id.name : null;
|
||||
}
|
||||
return left?.type === "Identifier" ? left.name : null;
|
||||
}
|
||||
|
||||
/** Expand `for (const el of [literal array]) {...}` and `[literal array].forEach((el, i) => {...})`. */
|
||||
function unrollOverArray(
|
||||
elements: Node[],
|
||||
body: Node[],
|
||||
elName: string | null,
|
||||
idxName: string | null,
|
||||
range: [number, number] | undefined,
|
||||
ctx: ExpandCtx,
|
||||
): Node[] {
|
||||
const out: Node[] = [];
|
||||
const site = ++ctx.site.n;
|
||||
elements.forEach((el, i) => {
|
||||
if (!el) return;
|
||||
const bindings = new Map<string, Node>();
|
||||
if (elName) bindings.set(elName, el);
|
||||
if (idxName) bindings.set(idxName, numericLiteral(i));
|
||||
const prov: GsapProvenance = { kind: "loop", callSite: site, iteration: i, sourceRange: range };
|
||||
out.push(...expandBody(body, bindings, prov, ctx));
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function unrollForOf(stmt: Node, ctx: ExpandCtx): Node[] | null {
|
||||
if (stmt.right?.type !== "ArrayExpression") return null;
|
||||
const elName = forOfVarName(stmt.left);
|
||||
if (!elName) return null;
|
||||
return unrollOverArray(
|
||||
stmt.right.elements ?? [],
|
||||
bodyStatements(stmt.body),
|
||||
elName,
|
||||
null,
|
||||
rangeOf(stmt),
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
|
||||
/** The (element, index) param names of a callback, or null if either is non-Identifier. */
|
||||
function callbackParamNames(cb: Node): { el: string | null; idx: string | null } | null {
|
||||
const names: Array<string | null> = [];
|
||||
for (const p of [cb.params?.[0], cb.params?.[1]]) {
|
||||
if (!p) names.push(null);
|
||||
else if (p.type !== "Identifier") return null;
|
||||
else names.push(p.name);
|
||||
}
|
||||
return { el: names[0]!, idx: names[1]! };
|
||||
}
|
||||
|
||||
/** True for `[arrayLiteral].forEach` member callees. */
|
||||
function isForEachCall(callee: Node): boolean {
|
||||
return (
|
||||
callee?.type === "MemberExpression" &&
|
||||
callee.property?.name === "forEach" &&
|
||||
callee.object?.type === "ArrayExpression"
|
||||
);
|
||||
}
|
||||
|
||||
/** The element array + callback of `[...].forEach(cb)`, or null. */
|
||||
function forEachTarget(call: Node): { elements: Node[]; cb: Node } | null {
|
||||
if (!isForEachCall(call.callee)) return null;
|
||||
const cb = call.arguments?.[0];
|
||||
return isFunctionNode(cb) ? { elements: call.callee.object.elements ?? [], cb } : null;
|
||||
}
|
||||
|
||||
function unrollForEach(call: Node, ctx: ExpandCtx): Node[] | null {
|
||||
const target = forEachTarget(call);
|
||||
if (!target) return null;
|
||||
const params = callbackParamNames(target.cb);
|
||||
if (!params) return null;
|
||||
return unrollOverArray(
|
||||
target.elements,
|
||||
bodyStatements(target.cb.body),
|
||||
params.el,
|
||||
params.idx,
|
||||
rangeOf(call),
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
|
||||
function expandCall(call: Node, ctx: ExpandCtx): Node[] | null {
|
||||
if (call.callee?.type === "Identifier" && ctx.helpers.has(call.callee.name)) {
|
||||
return inlineHelper(call, ctx);
|
||||
}
|
||||
return unrollForEach(call, ctx);
|
||||
}
|
||||
|
||||
function expandStatement(stmt: Node, ctx: ExpandCtx): Node[] | null {
|
||||
if (ctx.depth >= MAX_DEPTH) return null;
|
||||
if (stmt.type === "ForStatement") return unrollFor(stmt, ctx);
|
||||
if (stmt.type === "ForOfStatement") return unrollForOf(stmt, ctx);
|
||||
if (stmt.type === "ExpressionStatement" && stmt.expression?.type === "CallExpression") {
|
||||
return expandCall(stmt.expression, ctx);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function expandStatements(stmts: Node[], ctx: ExpandCtx): Node[] {
|
||||
const out: Node[] = [];
|
||||
for (const stmt of stmts) {
|
||||
const expanded = expandStatement(stmt, ctx);
|
||||
if (expanded) out.push(...expanded);
|
||||
else out.push(stmt);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite the Program body so helper invocations and bounded loops that build
|
||||
* the timeline are expanded into concrete per-call / per-iteration `tl.*`
|
||||
* statements, each tagged with provenance. Mutates `ast` in place (caller owns
|
||||
* the freshly-parsed tree). Constructs it can't statically resolve are left
|
||||
* untouched, so the parser falls back to current behavior for them.
|
||||
*/
|
||||
export function inlineComputedTimelines(
|
||||
ast: Node,
|
||||
timelineVar: string,
|
||||
resolve: LiteralResolver,
|
||||
): void {
|
||||
const helpers = collectInlinableHelpers(ast, timelineVar);
|
||||
const ctx: ExpandCtx = {
|
||||
helpers,
|
||||
timelineVar,
|
||||
resolve,
|
||||
depth: 0,
|
||||
site: { n: 0 },
|
||||
order: { n: 0 },
|
||||
};
|
||||
const body = (ast.body ?? []).filter((stmt: Node) => !isHelperDecl(stmt, helpers));
|
||||
ast.body = expandStatements(body, ctx);
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
// fallow-ignore-file code-duplication
|
||||
/**
|
||||
* T6b — acorn vs golden differential harness.
|
||||
*
|
||||
* Each corpus script runs through `parseGsapScriptAcorn` and must produce
|
||||
* output identical to the T6a golden files (captured from the recast/babel
|
||||
* baseline). Any mismatch = fidelity bug in the acorn port to fix before
|
||||
* recast is removed.
|
||||
*
|
||||
* Also includes the targeted preservation test (comments, custom JS, postamble)
|
||||
* and a coverage check against the fromTo / chained-call patterns.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { parseGsapScriptAcorn } from "./gsapParserAcorn.js";
|
||||
|
||||
const __goldens__ = join(fileURLToPath(import.meta.url), "..", "__goldens__");
|
||||
const g = (name: string) => join(__goldens__, name);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Corpus scripts — identical to gsapParser.golden.test.ts so goldens are shared
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MINIMAL_SCRIPT = `\
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
var notification = document.getElementById("notification");
|
||||
gsap.set(notification, { x: 420, opacity: 0 });
|
||||
tl.to(notification, { x: 0, opacity: 1, duration: 0.5, ease: "power3.out" }, 0.2);
|
||||
tl.to(notification, { x: 420, opacity: 0, duration: 0.3, ease: "power3.in" }, 4.2);
|
||||
window.__timelines["macos-notification"] = tl;`;
|
||||
|
||||
const MODERATE_SCRIPT = `\
|
||||
window.__timelines = window.__timelines || {};
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
var card = document.getElementById("card");
|
||||
var btn = document.getElementById("subscribe-btn");
|
||||
var textSub = document.getElementById("btn-subscribe");
|
||||
var textSubd = document.getElementById("btn-subscribed");
|
||||
gsap.set(card, { y: 300, opacity: 0 });
|
||||
tl.to(card, { y: 0, opacity: 1, duration: 0.5, ease: "power3.out" }, 0.1);
|
||||
tl.to(btn, { scale: 0.92, duration: 0.15, ease: "power2.out" }, 1.0);
|
||||
tl.to(btn, { scale: 1, duration: 0.4, ease: "elastic.out(1, 0.4)" }, 1.15);
|
||||
tl.to(textSub, { opacity: 0, duration: 0.08, ease: "none" }, 1.15);
|
||||
tl.to(textSubd, { opacity: 1, duration: 0.08, ease: "none" }, 1.18);
|
||||
tl.to(card, { y: 300, opacity: 0, duration: 0.25, ease: "power3.in" }, 3.8);
|
||||
window.__timelines["yt-lower-third"] = tl;`;
|
||||
|
||||
const COMPLEX_SCRIPT = `\
|
||||
window.__timelines = window.__timelines || {};
|
||||
gsap.defaults({ force3D: true });
|
||||
const tl = gsap.timeline({ paused: true, defaults: { duration: 0.45, ease: "power3.out" } });
|
||||
tl.from(".headline span", { y: 46, opacity: 0, stagger: 0.055, duration: 0.38, ease: "back.out(1.35)" }, 0.05)
|
||||
.from(".headline .sub", { y: 20, opacity: 0, duration: 0.28 }, 0.2)
|
||||
.from(".ambient-word", { scale: 0.92, opacity: 0, duration: 0.5 }, 0.08)
|
||||
.from(".ambient-line", { scaleX: 0, opacity: 0, stagger: 0.08, duration: 0.42 }, 0.16);
|
||||
window.__timelines["vpn-youtube-spot"] = tl;`;
|
||||
|
||||
const FROMTO_SCRIPT = `\
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
var hero = document.getElementById("hero");
|
||||
var caption = document.getElementById("caption");
|
||||
tl.fromTo(hero, { x: -200, opacity: 0 }, { x: 0, opacity: 1, duration: 0.6, ease: "power3.out" }, 0.1);
|
||||
tl.fromTo(caption, { y: -30, opacity: 0 }, { y: 0, opacity: 1, duration: 0.45 }, 0.5);
|
||||
window.__timelines["hero-reveal"] = tl;`;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T6b differential: acorn output must match T6a golden files
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("T6b — acorn vs recast golden differential", () => {
|
||||
it("minimal — matches golden (macos-notification)", async () => {
|
||||
const result = parseGsapScriptAcorn(MINIMAL_SCRIPT);
|
||||
await expect(JSON.stringify(result, null, 2)).toMatchFileSnapshot(g("minimal.parsed.json"));
|
||||
});
|
||||
|
||||
it("moderate — matches golden (yt-lower-third)", async () => {
|
||||
const result = parseGsapScriptAcorn(MODERATE_SCRIPT);
|
||||
await expect(JSON.stringify(result, null, 2)).toMatchFileSnapshot(g("moderate.parsed.json"));
|
||||
});
|
||||
|
||||
it("complex — matches golden (vpn-youtube-spot, chained .from() calls)", async () => {
|
||||
const result = parseGsapScriptAcorn(COMPLEX_SCRIPT);
|
||||
await expect(JSON.stringify(result, null, 2)).toMatchFileSnapshot(g("complex.parsed.json"));
|
||||
});
|
||||
|
||||
it("fromTo — matches golden (hero-reveal, negative positions)", async () => {
|
||||
const result = parseGsapScriptAcorn(FROMTO_SCRIPT);
|
||||
await expect(JSON.stringify(result, null, 2)).toMatchFileSnapshot(g("fromto.parsed.json"));
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T6b preservation test — the acorn claim: untouched code survives verbatim
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("T6b — preservation (comments, custom JS, postamble)", () => {
|
||||
it("preserves preamble and postamble around tween calls", () => {
|
||||
const script = `
|
||||
// author comment preserved
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to('#hero', { opacity: 1, duration: 0.5, ease: 'power2.out' });
|
||||
window.__timelines['scene'] = tl;
|
||||
`.trim();
|
||||
const result = parseGsapScriptAcorn(script);
|
||||
expect(result.preamble).toContain("// author comment preserved");
|
||||
expect(result.preamble).toContain("gsap.timeline");
|
||||
expect(result.postamble).toContain("window.__timelines");
|
||||
expect(result.postamble).toContain("scene");
|
||||
});
|
||||
|
||||
it("extracts correct animation from script with custom JS around tweens", () => {
|
||||
const script = `
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
var el = document.querySelector('.box');
|
||||
console.log('before tween');
|
||||
tl.to(el, { x: 100, duration: 0.5 }, 0);
|
||||
console.log('after tween');
|
||||
window.__timelines['custom'] = tl;
|
||||
`.trim();
|
||||
const result = parseGsapScriptAcorn(script);
|
||||
expect(result.animations).toHaveLength(1);
|
||||
expect(result.animations[0]?.targetSelector).toBe(".box");
|
||||
expect(result.animations[0]?.properties.x).toBe(100);
|
||||
expect(result.postamble).toContain("window.__timelines");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T6b structural coverage — patterns exercised by existing corpus
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("T6b — structural coverage", () => {
|
||||
it("resolves getElementById targets", () => {
|
||||
const script = `
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
var hero = document.getElementById("hero");
|
||||
tl.to(hero, { opacity: 1, duration: 0.5 }, 0);
|
||||
window.__timelines['t'] = tl;
|
||||
`.trim();
|
||||
const result = parseGsapScriptAcorn(script);
|
||||
expect(result.animations[0]?.targetSelector).toBe("#hero");
|
||||
});
|
||||
|
||||
it("resolves querySelector targets", () => {
|
||||
const script = `
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
var el = document.querySelector(".box");
|
||||
tl.to(el, { x: 50, duration: 0.3 }, 0);
|
||||
window.__timelines['t'] = tl;
|
||||
`.trim();
|
||||
const result = parseGsapScriptAcorn(script);
|
||||
expect(result.animations[0]?.targetSelector).toBe(".box");
|
||||
});
|
||||
|
||||
it("handles stagger as __raw: extra", () => {
|
||||
const script = `
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.from(".item", { y: 20, opacity: 0, stagger: 0.1, duration: 0.4 }, 0);
|
||||
window.__timelines['t'] = tl;
|
||||
`.trim();
|
||||
const result = parseGsapScriptAcorn(script);
|
||||
const anim = result.animations[0];
|
||||
expect(anim?.extras?.stagger).toBe("__raw:0.1");
|
||||
expect(anim?.properties).not.toHaveProperty("stagger");
|
||||
});
|
||||
|
||||
it("handles stagger as __raw: when expressed as object", () => {
|
||||
const script = `
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.from(".item", { y: 20, stagger: { each: 0.1, from: "start" }, duration: 0.4 }, 0);
|
||||
window.__timelines['t'] = tl;
|
||||
`.trim();
|
||||
const result = parseGsapScriptAcorn(script);
|
||||
const extras = result.animations[0]?.extras;
|
||||
const stagger = extras?.stagger;
|
||||
expect(typeof stagger).toBe("string");
|
||||
expect(typeof stagger === "string" && stagger.startsWith("__raw:")).toBe(true);
|
||||
expect(stagger).toContain("each");
|
||||
});
|
||||
|
||||
it("drops dropped keys (onComplete, onStart, onUpdate, onRepeat)", () => {
|
||||
const script = `
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.to(".box", { x: 100, duration: 0.5, onComplete: function() {}, onStart: function() {}, onUpdate: function() {}, onRepeat: function() {} }, 0);
|
||||
window.__timelines['t'] = tl;
|
||||
`.trim();
|
||||
const result = parseGsapScriptAcorn(script);
|
||||
const anim = result.animations[0];
|
||||
expect(anim?.properties).not.toHaveProperty("onComplete");
|
||||
expect(anim?.properties).not.toHaveProperty("onStart");
|
||||
expect(anim?.properties).not.toHaveProperty("onUpdate");
|
||||
expect(anim?.properties).not.toHaveProperty("onRepeat");
|
||||
expect(anim?.extras).toBeUndefined();
|
||||
});
|
||||
|
||||
it("assigns stable IDs based on selector + method + position", () => {
|
||||
const script = `
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.to(".a", { x: 1, duration: 0.5 }, 0);
|
||||
tl.to(".a", { x: 2, duration: 0.5 }, 0);
|
||||
window.__timelines['t'] = tl;
|
||||
`.trim();
|
||||
const result = parseGsapScriptAcorn(script);
|
||||
expect(result.animations[0]?.id).toBe(".a-to-0-position");
|
||||
expect(result.animations[1]?.id).toBe(".a-to-0-position-2");
|
||||
});
|
||||
|
||||
it("returns empty result on syntax error (graceful fail)", () => {
|
||||
const result = parseGsapScriptAcorn("this is not valid js {{{{");
|
||||
expect(result.animations).toHaveLength(0);
|
||||
expect(result.timelineVar).toBe("tl");
|
||||
});
|
||||
|
||||
it("detects multipleTimelines when script has >1 timeline", () => {
|
||||
const script = `
|
||||
var tl1 = gsap.timeline({ paused: true });
|
||||
var tl2 = gsap.timeline({ paused: true });
|
||||
tl1.to(".a", { x: 1, duration: 0.5 }, 0);
|
||||
window.__timelines['t'] = tl1;
|
||||
`.trim();
|
||||
const result = parseGsapScriptAcorn(script);
|
||||
expect(result.multipleTimelines).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* T6a — GSAP parser golden tests (baseline for the Recast → Meriyah swap).
|
||||
*
|
||||
* These snapshots capture the exact output of parseGsapScript +
|
||||
* serializeGsapAnimations under Recast/Babel before any parser change.
|
||||
* When the Meriyah swap lands, run `vitest --update-snapshots` to regenerate
|
||||
* and diff the goldens — any change is a regression candidate.
|
||||
*
|
||||
* Three representative scripts:
|
||||
* minimal — 2 tl.to calls, simple numeric selectors (macos-notification)
|
||||
* moderate — 6 tl.to calls, multiple selectors (yt-lower-third)
|
||||
* complex — stagger, chained .from()/.to(), const/defaults (vpn-youtube-spot)
|
||||
*/
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { parseGsapScriptAcorn as parseGsapScript } from "./gsapParserAcorn.js";
|
||||
import { serializeGsapAnimations } from "./gsapSerialize.js";
|
||||
|
||||
const __goldens__ = join(fileURLToPath(import.meta.url), "..", "__goldens__");
|
||||
const g = (name: string) => join(__goldens__, name);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Corpus scripts (inline so goldens are not coupled to registry file changes)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MINIMAL_SCRIPT = `\
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
var notification = document.getElementById("notification");
|
||||
gsap.set(notification, { x: 420, opacity: 0 });
|
||||
tl.to(notification, { x: 0, opacity: 1, duration: 0.5, ease: "power3.out" }, 0.2);
|
||||
tl.to(notification, { x: 420, opacity: 0, duration: 0.3, ease: "power3.in" }, 4.2);
|
||||
window.__timelines["macos-notification"] = tl;`;
|
||||
|
||||
const MODERATE_SCRIPT = `\
|
||||
window.__timelines = window.__timelines || {};
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
var card = document.getElementById("card");
|
||||
var btn = document.getElementById("subscribe-btn");
|
||||
var textSub = document.getElementById("btn-subscribe");
|
||||
var textSubd = document.getElementById("btn-subscribed");
|
||||
gsap.set(card, { y: 300, opacity: 0 });
|
||||
tl.to(card, { y: 0, opacity: 1, duration: 0.5, ease: "power3.out" }, 0.1);
|
||||
tl.to(btn, { scale: 0.92, duration: 0.15, ease: "power2.out" }, 1.0);
|
||||
tl.to(btn, { scale: 1, duration: 0.4, ease: "elastic.out(1, 0.4)" }, 1.15);
|
||||
tl.to(textSub, { opacity: 0, duration: 0.08, ease: "none" }, 1.15);
|
||||
tl.to(textSubd, { opacity: 1, duration: 0.08, ease: "none" }, 1.18);
|
||||
tl.to(card, { y: 300, opacity: 0, duration: 0.25, ease: "power3.in" }, 3.8);
|
||||
window.__timelines["yt-lower-third"] = tl;`;
|
||||
|
||||
const COMPLEX_SCRIPT = `\
|
||||
window.__timelines = window.__timelines || {};
|
||||
gsap.defaults({ force3D: true });
|
||||
const tl = gsap.timeline({ paused: true, defaults: { duration: 0.45, ease: "power3.out" } });
|
||||
tl.from(".headline span", { y: 46, opacity: 0, stagger: 0.055, duration: 0.38, ease: "back.out(1.35)" }, 0.05)
|
||||
.from(".headline .sub", { y: 20, opacity: 0, duration: 0.28 }, 0.2)
|
||||
.from(".ambient-word", { scale: 0.92, opacity: 0, duration: 0.5 }, 0.08)
|
||||
.from(".ambient-line", { scaleX: 0, opacity: 0, stagger: 0.08, duration: 0.42 }, 0.16);
|
||||
window.__timelines["vpn-youtube-spot"] = tl;`;
|
||||
|
||||
// fromTo: exercises the three-argument (fromArg, toArg, position) AST path and
|
||||
// negative numeric literals (UnaryExpression arm in resolveNode).
|
||||
const FROMTO_SCRIPT = `\
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
var hero = document.getElementById("hero");
|
||||
var caption = document.getElementById("caption");
|
||||
tl.fromTo(hero, { x: -200, opacity: 0 }, { x: 0, opacity: 1, duration: 0.6, ease: "power3.out" }, 0.1);
|
||||
tl.fromTo(caption, { y: -30, opacity: 0 }, { y: 0, opacity: 1, duration: 0.45 }, 0.5);
|
||||
window.__timelines["hero-reveal"] = tl;`;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function parseAndSerialize(script: string): { parsed: string; serialized: string } {
|
||||
const result = parseGsapScript(script);
|
||||
const serialized = serializeGsapAnimations(result.animations, result.timelineVar, {
|
||||
preamble: result.preamble,
|
||||
postamble: result.postamble,
|
||||
});
|
||||
return { parsed: JSON.stringify(result, null, 2), serialized };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Golden tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("T6a — GSAP parser golden tests (Recast/Babel baseline)", () => {
|
||||
describe("minimal — 2 tl.to calls (macos-notification)", () => {
|
||||
let parsed: string;
|
||||
let serialized: string;
|
||||
beforeAll(() => {
|
||||
({ parsed, serialized } = parseAndSerialize(MINIMAL_SCRIPT));
|
||||
});
|
||||
|
||||
it("parseGsapScript output matches golden", async () => {
|
||||
await expect(parsed).toMatchFileSnapshot(g("minimal.parsed.json"));
|
||||
});
|
||||
|
||||
it("serializeGsapAnimations output matches golden", async () => {
|
||||
await expect(serialized).toMatchFileSnapshot(g("minimal.serialized.js"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("moderate — 6 tl.to calls, multiple selectors (yt-lower-third)", () => {
|
||||
let parsed: string;
|
||||
let serialized: string;
|
||||
beforeAll(() => {
|
||||
({ parsed, serialized } = parseAndSerialize(MODERATE_SCRIPT));
|
||||
});
|
||||
|
||||
it("parseGsapScript output matches golden", async () => {
|
||||
await expect(parsed).toMatchFileSnapshot(g("moderate.parsed.json"));
|
||||
});
|
||||
|
||||
it("serializeGsapAnimations output matches golden", async () => {
|
||||
await expect(serialized).toMatchFileSnapshot(g("moderate.serialized.js"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("complex — stagger + chained .from() calls (vpn-youtube-spot)", () => {
|
||||
let parsed: string;
|
||||
let serialized: string;
|
||||
beforeAll(() => {
|
||||
({ parsed, serialized } = parseAndSerialize(COMPLEX_SCRIPT));
|
||||
});
|
||||
|
||||
it("parseGsapScript output matches golden", async () => {
|
||||
await expect(parsed).toMatchFileSnapshot(g("complex.parsed.json"));
|
||||
});
|
||||
|
||||
it("serializeGsapAnimations output matches golden", async () => {
|
||||
await expect(serialized).toMatchFileSnapshot(g("complex.serialized.js"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("fromTo — two tl.fromTo calls with negative positions (hero-reveal)", () => {
|
||||
let parsed: string;
|
||||
let serialized: string;
|
||||
beforeAll(() => {
|
||||
({ parsed, serialized } = parseAndSerialize(FROMTO_SCRIPT));
|
||||
});
|
||||
|
||||
it("parseGsapScript output matches golden", async () => {
|
||||
await expect(parsed).toMatchFileSnapshot(g("fromto.parsed.json"));
|
||||
});
|
||||
|
||||
it("serializeGsapAnimations output matches golden", async () => {
|
||||
await expect(serialized).toMatchFileSnapshot(g("fromto.serialized.js"));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
parseGsapScript,
|
||||
updateAnimationInScript,
|
||||
addAnimationToScript,
|
||||
removeAnimationFromScript,
|
||||
addKeyframeToScript,
|
||||
removeAllKeyframesFromScript,
|
||||
} from "./gsapParser.js";
|
||||
import { addLabelToScript, removeLabelFromScript } from "./gsapWriterAcorn.js";
|
||||
|
||||
// U4: recast parser/writer parity for the inline form
|
||||
// `window.__timelines["scene"] = gsap.timeline()` (the default server write path).
|
||||
|
||||
const inlineSrc = `window.__timelines = window.__timelines || {};
|
||||
window.__timelines["scene"] = gsap.timeline({ paused: true });
|
||||
window.__timelines["scene"].to("#a", { x: 100, duration: 1 }, 0);
|
||||
window.__timelines["scene"].to("#b", { y: 50, duration: 1 }, 0.5);`;
|
||||
|
||||
describe("recast — inline timeline read", () => {
|
||||
it("reads inline tweens (double quote)", () => {
|
||||
const p = parseGsapScript(inlineSrc);
|
||||
expect(p.unsupportedTimelinePattern).toBeFalsy();
|
||||
expect(p.animations).toHaveLength(2);
|
||||
expect(p.animations[0]!.targetSelector).toBe("#a");
|
||||
});
|
||||
|
||||
it("reads single-quote + dot access", () => {
|
||||
const sq = `window.__timelines['s'] = gsap.timeline();\nwindow.__timelines['s'].to('#a', { x: 1, duration: 1 }, 0);`;
|
||||
const dot = `window.__timelines.s = gsap.timeline();\nwindow.__timelines.s.to("#a", { x: 1, duration: 1 }, 0);`;
|
||||
expect(parseGsapScript(sq).animations).toHaveLength(1);
|
||||
expect(parseGsapScript(dot).animations).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("flags computed key as unsupported", () => {
|
||||
const c = `const id = "s";\nwindow.__timelines[id] = gsap.timeline();\nwindow.__timelines[id].to("#a", { x: 1, duration: 1 }, 0);`;
|
||||
expect(parseGsapScript(c).unsupportedTimelinePattern).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the canonical const form unchanged", () => {
|
||||
const c = `const tl = gsap.timeline();\nwindow.__timelines["s"] = tl;\ntl.to("#a", { x: 5, duration: 1 }, 0);`;
|
||||
const p = parseGsapScript(c);
|
||||
expect(p.timelineVar).toBe("tl");
|
||||
expect(p.animations).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("recast — inline timeline write", () => {
|
||||
it("edits an inline tween in place", () => {
|
||||
const id = parseGsapScript(inlineSrc).animations[0]!.id;
|
||||
const out = updateAnimationInScript(inlineSrc, id, { properties: { x: 200 } });
|
||||
expect(out).toContain('window.__timelines["scene"].to("#a"');
|
||||
expect(out).toContain("200");
|
||||
expect(parseGsapScript(out).animations).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("adds a tween in member form", () => {
|
||||
const out = addAnimationToScript(inlineSrc, {
|
||||
method: "to",
|
||||
targetSelector: "#c",
|
||||
properties: { opacity: 1 },
|
||||
position: 1,
|
||||
duration: 1,
|
||||
});
|
||||
const script = typeof out === "string" ? out : out.script;
|
||||
expect(script).toContain('window.__timelines["scene"].to("#c"');
|
||||
expect(parseGsapScript(script).animations).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("removes an inline tween", () => {
|
||||
const id = parseGsapScript(inlineSrc).animations[1]!.id;
|
||||
const out = removeAnimationFromScript(inlineSrc, id);
|
||||
expect(out).not.toContain('"#b"');
|
||||
expect(parseGsapScript(out).animations).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("adds + removes keyframes on an inline tween", () => {
|
||||
const id = parseGsapScript(inlineSrc).animations[0]!.id;
|
||||
const withKf = addKeyframeToScript(inlineSrc, id, 50, { x: 150 });
|
||||
expect(withKf).toContain("keyframes");
|
||||
expect(parseGsapScript(withKf).unsupportedTimelinePattern).toBeFalsy();
|
||||
const kfId = parseGsapScript(withKf).animations[0]!.id;
|
||||
const cleared = removeAllKeyframesFromScript(withKf, kfId);
|
||||
expect(cleared).not.toContain("keyframes");
|
||||
});
|
||||
|
||||
it("preserves single-quote member form on write", () => {
|
||||
const sq = `window.__timelines['s'] = gsap.timeline();\nwindow.__timelines['s'].to('#a', { x: 1, duration: 1 }, 0);`;
|
||||
const id = parseGsapScript(sq).animations[0]!.id;
|
||||
const out = updateAnimationInScript(sq, id, { properties: { x: 9 } });
|
||||
expect(out).toContain("window.__timelines['s']");
|
||||
});
|
||||
});
|
||||
|
||||
// acorn writer: inline-form label add/remove must match member-rooted callees, not
|
||||
// just Identifier-rooted ones — else addLabel duplicates and removeLabel no-ops.
|
||||
describe("acorn — inline timeline labels", () => {
|
||||
const src = `window.__timelines["scene"] = gsap.timeline({ paused: true });
|
||||
window.__timelines["scene"].to("#a", { x: 100, duration: 1 }, 0);`;
|
||||
|
||||
it("dedups addLabel (moves, not duplicates) and removes it on an inline timeline", () => {
|
||||
let s = addLabelToScript(src, "intro", 0.5);
|
||||
s = addLabelToScript(s, "intro", 0.9);
|
||||
expect((s.match(/addLabel\(/g) ?? []).length).toBe(1);
|
||||
expect(s).toContain('addLabel("intro", 0.9)');
|
||||
expect((removeLabelFromScript(s, "intro").match(/addLabel\(/g) ?? []).length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,947 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
parseGsapScript,
|
||||
serializeGsapAnimations,
|
||||
updateAnimationInScript,
|
||||
addAnimationToScript,
|
||||
removeAnimationFromScript,
|
||||
} from "./gsapParser.js";
|
||||
import type { ParsedGsap } from "./gsapParser.js";
|
||||
import {
|
||||
parseAndSerialize,
|
||||
parseSingleAnimation,
|
||||
expectStaggerRaw,
|
||||
expectRawWithResolvable,
|
||||
expectSingleAnimPosition,
|
||||
} from "./gsapParser.test-helpers.js";
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Assert a parse completed without crashing and returned the safe-default shape. */
|
||||
function expectSafeDefault(result: ParsedGsap) {
|
||||
expect(result).toBeDefined();
|
||||
expect(Array.isArray(result.animations)).toBe(true);
|
||||
expect(typeof result.timelineVar).toBe("string");
|
||||
}
|
||||
|
||||
/** Parse, serialize, re-parse, and assert structural equality of the animation IR. */
|
||||
function assertRoundTrip(script: string) {
|
||||
const parsed1 = parseGsapScript(script);
|
||||
expect(parsed1.animations.length).toBeGreaterThan(0);
|
||||
|
||||
const serialized = serializeGsapAnimations(parsed1.animations, parsed1.timelineVar, {
|
||||
preamble: parsed1.preamble,
|
||||
postamble: parsed1.postamble,
|
||||
});
|
||||
const parsed2 = parseGsapScript(serialized);
|
||||
|
||||
expect(parsed2.animations.length).toBe(parsed1.animations.length);
|
||||
for (let i = 0; i < parsed1.animations.length; i++) {
|
||||
const a = parsed1.animations[i];
|
||||
const b = parsed2.animations[i];
|
||||
expect(b.targetSelector).toBe(a.targetSelector);
|
||||
expect(b.method).toBe(a.method);
|
||||
expect(b.position).toEqual(a.position);
|
||||
expect(b.duration).toEqual(a.duration);
|
||||
expect(b.ease).toEqual(a.ease);
|
||||
// Properties: numeric values must match; __raw values may re-serialize differently
|
||||
for (const [key, val] of Object.entries(a.properties)) {
|
||||
if (typeof val === "number") {
|
||||
expect(b.properties[key]).toBe(val);
|
||||
} else if (typeof val === "string" && val.startsWith("__raw:")) {
|
||||
// Raw values survive in some form — just confirm the key exists
|
||||
expect(b.properties).toHaveProperty(key);
|
||||
}
|
||||
}
|
||||
// Extras survive
|
||||
if (a.extras) {
|
||||
expect(b.extras).toBeDefined();
|
||||
for (const key of Object.keys(a.extras)) {
|
||||
expect(b.extras).toHaveProperty(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 1. Malformed Scripts ───────────────────────────────────────────────────
|
||||
|
||||
describe("1. Malformed scripts", () => {
|
||||
const cases = [
|
||||
{
|
||||
name: "unclosed brace",
|
||||
script: "const tl = gsap.timeline({ paused: true }); tl.to('#a', { x: 1",
|
||||
},
|
||||
{
|
||||
name: "unclosed parenthesis",
|
||||
script: "const tl = gsap.timeline({ paused: true }); tl.to('#a', { x: 1 }, 0",
|
||||
},
|
||||
{ name: "random garbage", script: "@@@ not javascript at all ~~~" },
|
||||
{ name: "partial assignment", script: "const tl =" },
|
||||
{
|
||||
name: "missing semicolons everywhere",
|
||||
script:
|
||||
"const tl = gsap.timeline({ paused: true })\ntl.to('#a', { x: 1 }, 0)\ntl.to('#b', { y: 2 }, 1)",
|
||||
},
|
||||
{
|
||||
name: "double commas",
|
||||
script: 'const tl = gsap.timeline({ paused: true }); tl.to("#a",, { x: 1 }, 0);',
|
||||
},
|
||||
{ name: "HTML mixed in", script: "<div>hello</div>\nconst tl = gsap.timeline();" },
|
||||
{ name: "only opening brace", script: "{" },
|
||||
{ name: "only closing brace", script: "}" },
|
||||
{ name: "null byte", script: "const tl = gsap.timeline();\x00 tl.to('#a', { x: 1 }, 0);" },
|
||||
];
|
||||
|
||||
for (const { name, script } of cases) {
|
||||
it(`does not crash on: ${name}`, () => {
|
||||
const result = parseGsapScript(script);
|
||||
expectSafeDefault(result);
|
||||
});
|
||||
|
||||
it(`mutation functions are safe on: ${name}`, () => {
|
||||
// Some malformed scripts might parse as valid but empty — mutation safety
|
||||
// still applies (either noop or a valid transform)
|
||||
expect(() => updateAnimationInScript(script, "id", { duration: 1 })).not.toThrow();
|
||||
expect(() =>
|
||||
addAnimationToScript(script, {
|
||||
targetSelector: "#el",
|
||||
method: "to",
|
||||
position: 0,
|
||||
properties: { opacity: 1 },
|
||||
duration: 1,
|
||||
}),
|
||||
).not.toThrow();
|
||||
expect(() => removeAnimationFromScript(script, "id")).not.toThrow();
|
||||
});
|
||||
}
|
||||
|
||||
it("missing semicolons still parse tweens (ASI)", () => {
|
||||
const script = `
|
||||
const tl = gsap.timeline({ paused: true })
|
||||
tl.to("#a", { x: 100, duration: 0.5 }, 0)
|
||||
tl.to("#b", { y: 200, duration: 1 }, 1)
|
||||
`;
|
||||
const result = parseGsapScript(script);
|
||||
// Babel handles ASI — these should parse fine
|
||||
expect(result.animations.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 2. Empty / Minimal Scripts ─────────────────────────────────────────────
|
||||
|
||||
describe("2. Empty / minimal scripts", () => {
|
||||
it("empty string", () => {
|
||||
const result = parseGsapScript("");
|
||||
expectSafeDefault(result);
|
||||
expect(result.animations).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("whitespace only", () => {
|
||||
const result = parseGsapScript(" \n\t\n ");
|
||||
expectSafeDefault(result);
|
||||
expect(result.animations).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("window.__timelines = {} with no tweens", () => {
|
||||
const script = "window.__timelines = {};";
|
||||
const result = parseGsapScript(script);
|
||||
expectSafeDefault(result);
|
||||
expect(result.animations).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("timeline declaration with no tween calls", () => {
|
||||
const script = "const tl = gsap.timeline({ paused: true });";
|
||||
const result = parseGsapScript(script);
|
||||
expect(result.timelineVar).toBe("tl");
|
||||
expect(result.animations).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("only comments", () => {
|
||||
const script = "// this is a comment\n/* block comment */";
|
||||
const result = parseGsapScript(script);
|
||||
expectSafeDefault(result);
|
||||
expect(result.animations).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("tween with only selector and empty vars object", () => {
|
||||
const script = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#el", {}, 0);
|
||||
`;
|
||||
const result = parseGsapScript(script);
|
||||
expect(result.animations).toHaveLength(1);
|
||||
expect(Object.keys(result.animations[0].properties)).toHaveLength(0);
|
||||
expect(result.animations[0].duration).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── 3. Extreme Values ──────────────────────────────────────────────────────
|
||||
|
||||
describe("3. Extreme values", () => {
|
||||
it("very large numbers", () => {
|
||||
const script = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#el", { x: 1e10, y: 99999999, duration: 1000000 }, 0);
|
||||
`;
|
||||
const result = parseGsapScript(script);
|
||||
expect(result.animations).toHaveLength(1);
|
||||
expect(result.animations[0].properties.x).toBe(1e10);
|
||||
expect(result.animations[0].properties.y).toBe(99999999);
|
||||
expect(result.animations[0].duration).toBe(1000000);
|
||||
});
|
||||
|
||||
it("very small numbers", () => {
|
||||
const script = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#el", { opacity: 0.001, duration: 0.0001 }, 0.00001);
|
||||
`;
|
||||
const result = parseGsapScript(script);
|
||||
expect(result.animations).toHaveLength(1);
|
||||
expect(result.animations[0].properties.opacity).toBe(0.001);
|
||||
expect(result.animations[0].duration).toBe(0.0001);
|
||||
expect(result.animations[0].position).toBeCloseTo(0.00001);
|
||||
});
|
||||
|
||||
it("negative position", () => {
|
||||
const script = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#el", { x: 100, duration: 1 }, -5);
|
||||
`;
|
||||
const result = parseGsapScript(script);
|
||||
expect(result.animations).toHaveLength(1);
|
||||
expect(result.animations[0].position).toBe(-5);
|
||||
});
|
||||
|
||||
it("zero duration", () => {
|
||||
const script = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#el", { x: 100, duration: 0 }, 0);
|
||||
`;
|
||||
const result = parseGsapScript(script);
|
||||
expect(result.animations[0].duration).toBe(0);
|
||||
});
|
||||
|
||||
it("NaN-producing division by zero is handled", () => {
|
||||
const script = `
|
||||
const ZERO = 0;
|
||||
const BAD = 100 / ZERO;
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#el", { x: BAD, y: 50, duration: 1 }, 0);
|
||||
`;
|
||||
const result = parseGsapScript(script);
|
||||
expect(result.animations).toHaveLength(1);
|
||||
// Division by zero returns undefined from resolveNode, so BAD is unresolvable
|
||||
// x should be __raw: or undefined, y should be 50
|
||||
expect(result.animations[0].properties.y).toBe(50);
|
||||
// BAD was never bound (division by zero returns undefined), so the reference is raw
|
||||
const xVal = result.animations[0].properties.x;
|
||||
expect(typeof xVal === "string" && xVal.startsWith("__raw:")).toBe(true);
|
||||
});
|
||||
|
||||
it("Infinity literal", () => {
|
||||
expectRawWithResolvable(
|
||||
`
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#el", { x: Infinity, y: 50, duration: 1 }, 0);
|
||||
`,
|
||||
"x",
|
||||
"y",
|
||||
50,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 4. Unicode in Selectors ────────────────────────────────────────────────
|
||||
|
||||
describe("4. Unicode in selectors", () => {
|
||||
it("Japanese characters in selector", () => {
|
||||
const script = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#日本語", { x: 100, duration: 1 }, 0);
|
||||
`;
|
||||
const result = parseGsapScript(script);
|
||||
expect(result.animations).toHaveLength(1);
|
||||
expect(result.animations[0].targetSelector).toBe("#日本語");
|
||||
});
|
||||
|
||||
it("emoji in selector", () => {
|
||||
const script = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#rocket-🚀", { opacity: 1, duration: 0.5 }, 0);
|
||||
`;
|
||||
const result = parseGsapScript(script);
|
||||
expect(result.animations).toHaveLength(1);
|
||||
expect(result.animations[0].targetSelector).toBe("#rocket-🚀");
|
||||
});
|
||||
|
||||
it("Arabic and Cyrillic selectors", () => {
|
||||
const script = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#عربي", { x: 50, duration: 1 }, 0);
|
||||
tl.to("#кириллица", { y: 100, duration: 1 }, 1);
|
||||
`;
|
||||
const result = parseGsapScript(script);
|
||||
expect(result.animations).toHaveLength(2);
|
||||
expect(result.animations[0].targetSelector).toBe("#عربي");
|
||||
expect(result.animations[1].targetSelector).toBe("#кириллица");
|
||||
});
|
||||
|
||||
it("class selector with unicode", () => {
|
||||
const script = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to(".コンポーネント", { scale: 2, duration: 0.5 }, 0);
|
||||
`;
|
||||
const result = parseGsapScript(script);
|
||||
expect(result.animations[0].targetSelector).toBe(".コンポーネント");
|
||||
});
|
||||
});
|
||||
|
||||
// ── 5. Deeply Nested Objects ───────────────────────────────────────────────
|
||||
|
||||
describe("5. Deeply nested objects", () => {
|
||||
it("complex stagger object preserved in extras", () => {
|
||||
const script = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to(".items", { opacity: 1, duration: 0.5, stagger: { amount: 1, grid: [3, 3], from: "center", axis: "x" } }, 0);
|
||||
`;
|
||||
const anim = parseSingleAnimation(script);
|
||||
expectStaggerRaw(anim, "amount", "grid", "center");
|
||||
});
|
||||
|
||||
it("complex stagger survives round-trip serialization", () => {
|
||||
const script = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to(".items", { opacity: 1, duration: 0.5, stagger: { amount: 1, grid: [3, 3], from: "center", axis: "x" } }, 0);
|
||||
`;
|
||||
const { serialized } = parseAndSerialize(script);
|
||||
expect(serialized).toContain("stagger:");
|
||||
expect(serialized).toContain("amount");
|
||||
expect(serialized).toContain("grid");
|
||||
});
|
||||
|
||||
it("nested ease config object (non-string ease)", () => {
|
||||
const script = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#el", { x: 100, duration: 1, ease: "back.out(1.7)" }, 0);
|
||||
`;
|
||||
const result = parseGsapScript(script);
|
||||
expect(result.animations[0].ease).toBe("back.out(1.7)");
|
||||
});
|
||||
});
|
||||
|
||||
// ── 6. Chained Method Calls ────────────────────────────────────────────────
|
||||
|
||||
describe("6. Chained method calls", () => {
|
||||
it("chained tl.to().to().from() — every link is detected", () => {
|
||||
// Each link of a chain is called on the return value of the previous one
|
||||
// (ultimately the timeline). The parser walks the member chain to its root,
|
||||
// so every link is captured, not just the first.
|
||||
const script = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#a", { x: 100, duration: 0.5 }, 0).to("#b", { y: 200, duration: 0.5 }, 1).from("#c", { scale: 0, duration: 1 }, 2);
|
||||
`;
|
||||
const result = parseGsapScript(script);
|
||||
expect(result.animations).toHaveLength(3);
|
||||
const bySelector = Object.fromEntries(result.animations.map((a) => [a.targetSelector, a]));
|
||||
expect(bySelector["#a"]?.properties.x).toBe(100);
|
||||
expect(bySelector["#b"]?.properties.y).toBe(200);
|
||||
expect(bySelector["#c"]?.method).toBe("from");
|
||||
});
|
||||
|
||||
it("separate statements all parse", () => {
|
||||
const script = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#a", { x: 100, duration: 0.5 }, 0);
|
||||
tl.to("#b", { y: 200, duration: 0.5 }, 1);
|
||||
tl.from("#c", { scale: 0, duration: 1 }, 2);
|
||||
`;
|
||||
const result = parseGsapScript(script);
|
||||
expect(result.animations).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 7. Template Literals in Values ─────────────────────────────────────────
|
||||
|
||||
describe("7. Template literals in values", () => {
|
||||
it("template literal with no expressions resolves to string", () => {
|
||||
const script = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#el", { x: 50, duration: 1, ease: \`power2.out\` }, 0);
|
||||
`;
|
||||
const result = parseGsapScript(script);
|
||||
expect(result.animations).toHaveLength(1);
|
||||
expect(result.animations[0].ease).toBe("power2.out");
|
||||
});
|
||||
|
||||
it("template literal with expression becomes __raw", () => {
|
||||
expectRawWithResolvable(
|
||||
`
|
||||
const val = 100;
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#el", { x: \`\${val}px\`, y: 50, duration: 1 }, 0);
|
||||
`,
|
||||
"x",
|
||||
"y",
|
||||
50,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 8. Multiple Scripts in One HTML ────────────────────────────────────────
|
||||
|
||||
describe("8. Multiple timelines", () => {
|
||||
it("two gsap.timeline() calls sets multipleTimelines flag", () => {
|
||||
const script = `
|
||||
const tl1 = gsap.timeline({ paused: true });
|
||||
tl1.to("#a", { x: 100, duration: 1 }, 0);
|
||||
const tl2 = gsap.timeline({ paused: true });
|
||||
tl2.to("#b", { y: 200, duration: 1 }, 0);
|
||||
`;
|
||||
const result = parseGsapScript(script);
|
||||
expect(result.multipleTimelines).toBe(true);
|
||||
// Parser only tracks the first timeline variable
|
||||
expect(result.timelineVar).toBe("tl1");
|
||||
// Only tl1 tweens are captured
|
||||
expect(result.animations.every((a) => a.targetSelector === "#a")).toBe(true);
|
||||
});
|
||||
|
||||
it("two scripts concatenated with same variable name", () => {
|
||||
const script = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#first", { opacity: 1, duration: 0.5 }, 0);
|
||||
// Second block re-uses tl but creates a new timeline
|
||||
const tl2 = gsap.timeline({ paused: true });
|
||||
tl.to("#second", { opacity: 0.5, duration: 1 }, 1);
|
||||
`;
|
||||
const result = parseGsapScript(script);
|
||||
// Both .to() calls use "tl" as the callee object, so both are captured
|
||||
expect(result.multipleTimelines).toBe(true);
|
||||
const selectors = result.animations.map((a) => a.targetSelector);
|
||||
expect(selectors).toContain("#first");
|
||||
expect(selectors).toContain("#second");
|
||||
});
|
||||
});
|
||||
|
||||
// ── 9. Comments Everywhere ─────────────────────────────────────────────────
|
||||
|
||||
describe("9. Comments everywhere", () => {
|
||||
it("inline comments inside tween args", () => {
|
||||
const script = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#el", { /* fade in */ opacity: 1 /*, y: 200*/, duration: 0.5 }, 0);
|
||||
`;
|
||||
const result = parseGsapScript(script);
|
||||
expect(result.animations).toHaveLength(1);
|
||||
expect(result.animations[0].properties.opacity).toBe(1);
|
||||
// y: 200 is commented out, should not appear
|
||||
expect(result.animations[0].properties).not.toHaveProperty("y");
|
||||
});
|
||||
|
||||
it("line comments between tween calls", () => {
|
||||
const script = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
// First animation
|
||||
tl.set("#el", { opacity: 0 }, 0);
|
||||
// Second animation
|
||||
tl.to("#el", { opacity: 1, duration: 1 }, 0.5);
|
||||
// Done
|
||||
`;
|
||||
const result = parseGsapScript(script);
|
||||
expect(result.animations).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("comment inside selector string (not really a comment)", () => {
|
||||
const script = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#el /* not a comment */", { x: 100, duration: 1 }, 0);
|
||||
`;
|
||||
const result = parseGsapScript(script);
|
||||
expect(result.animations).toHaveLength(1);
|
||||
expect(result.animations[0].targetSelector).toBe("#el /* not a comment */");
|
||||
});
|
||||
});
|
||||
|
||||
// ── 10. Arrow Functions as Values ──────────────────────────────────────────
|
||||
|
||||
describe("10. Arrow functions as values", () => {
|
||||
it("arrow function property becomes __raw", () => {
|
||||
expectRawWithResolvable(
|
||||
`
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#el", { x: (i) => i * 50, opacity: 1, duration: 1 }, 0);
|
||||
`,
|
||||
"x",
|
||||
"opacity",
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it("arrow function in stagger becomes __raw extra", () => {
|
||||
const script = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to(".items", { opacity: 1, duration: 0.5, stagger: (i) => i * 0.1 }, 0);
|
||||
`;
|
||||
const anim = parseSingleAnimation(script);
|
||||
expectStaggerRaw(anim);
|
||||
});
|
||||
|
||||
it("arrow function round-trips via serialization", () => {
|
||||
const script = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#el", { x: (i) => i * 50, opacity: 1, duration: 1 }, 0);
|
||||
`;
|
||||
const { serialized } = parseAndSerialize(script);
|
||||
// The raw arrow function should be emitted without quotes
|
||||
expect(serialized).toContain("(i) => i * 50");
|
||||
expect(serialized).not.toContain('"(i) => i * 50"');
|
||||
});
|
||||
});
|
||||
|
||||
// ── 11. Spread Operator ────────────────────────────────────────────────────
|
||||
|
||||
describe("11. Spread operator", () => {
|
||||
it("spread in vars object does not crash — spread properties are skipped", () => {
|
||||
const script = `
|
||||
const baseVars = { opacity: 0, x: -50 };
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#el", { ...baseVars, y: 100, duration: 1 }, 0);
|
||||
`;
|
||||
const result = parseGsapScript(script);
|
||||
expect(result.animations).toHaveLength(1);
|
||||
// Spread properties are SpreadElement, not ObjectProperty — they're skipped
|
||||
// Only explicitly written properties are captured
|
||||
expect(result.animations[0].properties.y).toBe(100);
|
||||
expect(result.animations[0].duration).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 12. Conditional Expressions ────────────────────────────────────────────
|
||||
|
||||
describe("12. Conditional expressions", () => {
|
||||
it("ternary expression becomes __raw", () => {
|
||||
expectRawWithResolvable(
|
||||
`
|
||||
const condition = true;
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#el", { x: condition ? 100 : 200, y: 50, duration: 1 }, 0);
|
||||
`,
|
||||
"x",
|
||||
"y",
|
||||
50,
|
||||
);
|
||||
});
|
||||
|
||||
it("conditional in position argument defaults to 0", () => {
|
||||
expectSingleAnimPosition(
|
||||
`
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#el", { x: 100, duration: 1 }, someCondition ? 0 : 2);
|
||||
`,
|
||||
0,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 13. Round-Trip Stability ───────────────────────────────────────────────
|
||||
|
||||
describe("13. Round-trip stability", () => {
|
||||
it("basic .to() round-trips", () => {
|
||||
assertRoundTrip(`
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#el", { opacity: 1, x: 50, duration: 0.5, ease: "power2.out" }, 0);
|
||||
`);
|
||||
});
|
||||
|
||||
it("basic .from() round-trips", () => {
|
||||
assertRoundTrip(`
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.from("#el", { opacity: 0, y: -100, duration: 1, ease: "back.out" }, 0.5);
|
||||
`);
|
||||
});
|
||||
|
||||
it("basic .set() round-trips", () => {
|
||||
assertRoundTrip(`
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.set("#el", { opacity: 0, scale: 0.5 }, 0);
|
||||
`);
|
||||
});
|
||||
|
||||
it("basic .fromTo() round-trips", () => {
|
||||
assertRoundTrip(`
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.fromTo("#el", { opacity: 0 }, { opacity: 1, duration: 1, ease: "power1.inOut" }, 2);
|
||||
`);
|
||||
});
|
||||
|
||||
it("stagger extra round-trips", () => {
|
||||
assertRoundTrip(`
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to(".items", { opacity: 1, duration: 0.5, stagger: 0.1 }, 0);
|
||||
`);
|
||||
});
|
||||
|
||||
it("yoyo + repeat extras round-trip", () => {
|
||||
assertRoundTrip(`
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#el", { x: 100, duration: 1, yoyo: true, repeat: 3, repeatDelay: 0.2 }, 0);
|
||||
`);
|
||||
});
|
||||
|
||||
it("multiple tweens round-trip with ordering preserved", () => {
|
||||
assertRoundTrip(`
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.set("#el1", { opacity: 0 }, 0);
|
||||
tl.to("#el1", { opacity: 1, duration: 0.5 }, 0);
|
||||
tl.to("#el2", { x: 100, duration: 1 }, 1);
|
||||
tl.from("#el3", { y: -50, duration: 0.3 }, 2);
|
||||
`);
|
||||
});
|
||||
|
||||
it("string position round-trips", () => {
|
||||
const script = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#el1", { opacity: 1, duration: 0.5 }, "+=1");
|
||||
tl.to("#el2", { x: 100, duration: 1 }, "<");
|
||||
`;
|
||||
const parsed1 = parseGsapScript(script);
|
||||
const serialized = serializeGsapAnimations(parsed1.animations, parsed1.timelineVar, {
|
||||
preamble: parsed1.preamble,
|
||||
postamble: parsed1.postamble,
|
||||
});
|
||||
const parsed2 = parseGsapScript(serialized);
|
||||
expect(parsed2.animations[0].position).toBe("+=1");
|
||||
expect(parsed2.animations[1].position).toBe("<");
|
||||
});
|
||||
|
||||
it("double round-trip: parse -> serialize -> parse -> serialize -> parse gives stable IR", () => {
|
||||
const script = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.set("#a", { opacity: 0 }, 0);
|
||||
tl.to("#a", { opacity: 1, x: 100, duration: 0.5, ease: "power2.out" }, 0.5);
|
||||
tl.to("#b", { y: -50, scale: 1.5, duration: 1, stagger: 0.1 }, 1);
|
||||
`;
|
||||
const parsed1 = parseGsapScript(script);
|
||||
const ser1 = serializeGsapAnimations(parsed1.animations, parsed1.timelineVar, {
|
||||
preamble: parsed1.preamble,
|
||||
postamble: parsed1.postamble,
|
||||
});
|
||||
const parsed2 = parseGsapScript(ser1);
|
||||
const ser2 = serializeGsapAnimations(parsed2.animations, parsed2.timelineVar, {
|
||||
preamble: parsed2.preamble,
|
||||
postamble: parsed2.postamble,
|
||||
});
|
||||
const parsed3 = parseGsapScript(ser2);
|
||||
|
||||
// Third parse should match second parse exactly
|
||||
expect(parsed3.animations.length).toBe(parsed2.animations.length);
|
||||
for (let i = 0; i < parsed2.animations.length; i++) {
|
||||
expect(parsed3.animations[i].targetSelector).toBe(parsed2.animations[i].targetSelector);
|
||||
expect(parsed3.animations[i].method).toBe(parsed2.animations[i].method);
|
||||
expect(parsed3.animations[i].position).toEqual(parsed2.animations[i].position);
|
||||
expect(parsed3.animations[i].properties).toEqual(parsed2.animations[i].properties);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── 14. ID Collision ───────────────────────────────────────────────────────
|
||||
|
||||
describe("14. ID collision", () => {
|
||||
it("three tweens with same selector, method, position get disambiguated", () => {
|
||||
const script = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#el", { opacity: 0, duration: 0.3 }, 0);
|
||||
tl.to("#el", { x: 100, duration: 0.5 }, 0);
|
||||
tl.to("#el", { y: 50, duration: 0.7 }, 0);
|
||||
`;
|
||||
const result = parseGsapScript(script);
|
||||
expect(result.animations).toHaveLength(3);
|
||||
const ids = result.animations.map((a) => a.id);
|
||||
// All IDs must be unique
|
||||
expect(new Set(ids).size).toBe(3);
|
||||
expect(ids[0]).toBe("#el-to-0-visual");
|
||||
expect(ids[1]).toBe("#el-to-0-position");
|
||||
expect(ids[2]).toBe("#el-to-0-position-2");
|
||||
});
|
||||
|
||||
it("disambiguated IDs are stable across parses", () => {
|
||||
const script = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#el", { opacity: 0 }, 0);
|
||||
tl.to("#el", { x: 100 }, 0);
|
||||
`;
|
||||
const r1 = parseGsapScript(script);
|
||||
const r2 = parseGsapScript(script);
|
||||
expect(r1.animations[0].id).toBe(r2.animations[0].id);
|
||||
expect(r1.animations[1].id).toBe(r2.animations[1].id);
|
||||
});
|
||||
|
||||
it("mutation by ID targets the correct animation among collisions", () => {
|
||||
const script = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#el", { opacity: 0, duration: 0.3 }, 0);
|
||||
tl.to("#el", { opacity: 1, duration: 0.5 }, 0);
|
||||
`;
|
||||
const parsed = parseGsapScript(script);
|
||||
const secondId = parsed.animations[1].id; // "#el-to-0-2"
|
||||
const updated = updateAnimationInScript(script, secondId, { duration: 2 });
|
||||
const reparsed = parseGsapScript(updated);
|
||||
// The second animation should have updated duration
|
||||
expect(reparsed.animations[1].duration).toBe(2);
|
||||
// The first should be untouched
|
||||
expect(reparsed.animations[0].duration).toBe(0.3);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 15. Very Long Scripts ──────────────────────────────────────────────────
|
||||
|
||||
describe("15. Very long scripts (50+ tweens)", () => {
|
||||
it("parses 50 sequential tweens", () => {
|
||||
const tweens = Array.from(
|
||||
{ length: 50 },
|
||||
(_, i) =>
|
||||
`tl.to("#el${i}", { x: ${i * 10}, opacity: ${(i % 10) / 10}, duration: 0.5 }, ${i * 0.5});`,
|
||||
).join("\n ");
|
||||
const script = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
${tweens}
|
||||
`;
|
||||
const result = parseGsapScript(script);
|
||||
expect(result.animations).toHaveLength(50);
|
||||
// Spot check first and last
|
||||
expect(result.animations[0].targetSelector).toBe("#el0");
|
||||
expect(result.animations[0].properties.x).toBe(0);
|
||||
expect(result.animations[49].targetSelector).toBe("#el49");
|
||||
expect(result.animations[49].properties.x).toBe(490);
|
||||
});
|
||||
|
||||
it("parses 100 tweens targeting the same element", () => {
|
||||
const tweens = Array.from(
|
||||
{ length: 100 },
|
||||
(_, i) => `tl.to("#el", { x: ${i}, duration: 0.1 }, ${i * 0.1});`,
|
||||
).join("\n ");
|
||||
const script = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
${tweens}
|
||||
`;
|
||||
const result = parseGsapScript(script);
|
||||
expect(result.animations).toHaveLength(100);
|
||||
// All IDs must be unique despite same selector
|
||||
const ids = result.animations.map((a) => a.id);
|
||||
expect(new Set(ids).size).toBe(100);
|
||||
});
|
||||
|
||||
it("round-trips 50 tweens", () => {
|
||||
const tweens = Array.from(
|
||||
{ length: 50 },
|
||||
(_, i) => `tl.to("#el${i}", { x: ${i * 10}, duration: 0.5 }, ${i * 0.5});`,
|
||||
).join("\n ");
|
||||
const script = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
${tweens}
|
||||
`;
|
||||
const parsed = parseGsapScript(script);
|
||||
const serialized = serializeGsapAnimations(parsed.animations, parsed.timelineVar, {
|
||||
preamble: parsed.preamble,
|
||||
postamble: parsed.postamble,
|
||||
});
|
||||
const reparsed = parseGsapScript(serialized);
|
||||
expect(reparsed.animations.length).toBe(50);
|
||||
for (let i = 0; i < 50; i++) {
|
||||
expect(reparsed.animations[i].targetSelector).toBe(parsed.animations[i].targetSelector);
|
||||
expect(reparsed.animations[i].properties.x).toBe(parsed.animations[i].properties.x);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── Additional Edge Cases ──────────────────────────────────────────────────
|
||||
|
||||
describe("Additional edge cases", () => {
|
||||
it("selector with special CSS characters", () => {
|
||||
const script = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#my-element_v2.class", { x: 100, duration: 1 }, 0);
|
||||
tl.to(".parent > .child", { y: 50, duration: 0.5 }, 0);
|
||||
tl.to("[data-anim='fade']", { opacity: 1, duration: 1 }, 0);
|
||||
`;
|
||||
const result = parseGsapScript(script);
|
||||
expect(result.animations).toHaveLength(3);
|
||||
expect(result.animations[0].targetSelector).toBe("#my-element_v2.class");
|
||||
expect(result.animations[1].targetSelector).toBe(".parent > .child");
|
||||
expect(result.animations[2].targetSelector).toBe("[data-anim='fade']");
|
||||
});
|
||||
|
||||
it("string concatenation in property value", () => {
|
||||
const script = `
|
||||
const prefix = "100";
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#el", { x: prefix + "px", y: 50, duration: 1 }, 0);
|
||||
`;
|
||||
const result = parseGsapScript(script);
|
||||
expect(result.animations[0].properties.x).toBe("100px");
|
||||
expect(result.animations[0].properties.y).toBe(50);
|
||||
});
|
||||
|
||||
it("arithmetic in position argument", () => {
|
||||
const script = `
|
||||
const START = 2;
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#el", { x: 100, duration: 1 }, START + 0.5);
|
||||
`;
|
||||
const result = parseGsapScript(script);
|
||||
expect(result.animations[0].position).toBe(2.5);
|
||||
});
|
||||
|
||||
it("var declaration for timeline", () => {
|
||||
const script = `
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.to("#el", { opacity: 1, duration: 1 }, 0);
|
||||
`;
|
||||
const result = parseGsapScript(script);
|
||||
expect(result.timelineVar).toBe("tl");
|
||||
expect(result.animations).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("assignment expression for timeline (no declaration keyword)", () => {
|
||||
const script = `
|
||||
window.tl = gsap.timeline({ paused: true });
|
||||
`;
|
||||
const result = parseGsapScript(script);
|
||||
// Window member expression is not a bare Identifier, so timelineVar may not be found
|
||||
// The parser checks for Identifier left in assignment expressions
|
||||
// window.tl is a MemberExpression, not Identifier — should not set timelineVar
|
||||
expectSafeDefault(result);
|
||||
});
|
||||
|
||||
it("non-GSAP method calls on the timeline are ignored", () => {
|
||||
const script = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#el", { opacity: 1, duration: 0.5 }, 0);
|
||||
tl.play();
|
||||
tl.pause();
|
||||
tl.reverse();
|
||||
tl.seek(2);
|
||||
`;
|
||||
const result = parseGsapScript(script);
|
||||
// Only .to() is a tween method — play/pause/reverse/seek are not in GSAP_METHODS
|
||||
expect(result.animations).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("tween with only one argument (selector only) is skipped", () => {
|
||||
const script = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#el");
|
||||
tl.to("#el2", { opacity: 1, duration: 0.5 }, 0);
|
||||
`;
|
||||
const result = parseGsapScript(script);
|
||||
// First tween has < 2 args — should be skipped
|
||||
expect(result.animations).toHaveLength(1);
|
||||
expect(result.animations[0].targetSelector).toBe("#el2");
|
||||
});
|
||||
|
||||
it("resolves a variable reference selector to its queried CSS selector", () => {
|
||||
const script = `
|
||||
const el = document.querySelector("#el");
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to(el, { opacity: 1, duration: 0.5 }, 0);
|
||||
tl.to("#el2", { x: 100, duration: 0.5 }, 0);
|
||||
`;
|
||||
const result = parseGsapScript(script);
|
||||
// `el` is bound to `document.querySelector("#el")`, so it resolves to "#el".
|
||||
expect(result.animations).toHaveLength(2);
|
||||
expect(result.animations[0].targetSelector).toBe("#el");
|
||||
expect(result.animations[1].targetSelector).toBe("#el2");
|
||||
});
|
||||
|
||||
it("marks a variable target that is not bound to a DOM lookup as __unresolved__", () => {
|
||||
const script = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to(mysteryTarget, { opacity: 1, duration: 0.5 }, 0);
|
||||
tl.to("#el2", { x: 100, duration: 0.5 }, 0);
|
||||
`;
|
||||
const result = parseGsapScript(script);
|
||||
// mysteryTarget has no resolvable selector binding — kept with __unresolved__ marker.
|
||||
expect(result.animations).toHaveLength(2);
|
||||
expect(result.animations[0].targetSelector).toBe("__unresolved__");
|
||||
expect(result.animations[0].hasUnresolvedSelector).toBe(true);
|
||||
expect(result.animations[1].targetSelector).toBe("#el2");
|
||||
});
|
||||
|
||||
it("boolean values in vars are not included in properties", () => {
|
||||
const script = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#el", { opacity: 1, immediateRender: false, duration: 0.5 }, 0);
|
||||
`;
|
||||
const result = parseGsapScript(script);
|
||||
expect(result.animations[0].properties.opacity).toBe(1);
|
||||
// immediateRender is in EXTRAS_KEYS, should be in extras
|
||||
expect(result.animations[0].extras).toBeDefined();
|
||||
expect(result.animations[0].extras!.immediateRender).toBeDefined();
|
||||
// Should not be in properties
|
||||
expect(result.animations[0].properties).not.toHaveProperty("immediateRender");
|
||||
});
|
||||
|
||||
it("callbacks (onComplete etc.) are dropped", () => {
|
||||
// Note: validation would flag these, but the parser just drops them
|
||||
const script = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#el", { opacity: 1, duration: 1, onComplete: function() { console.log("done"); } }, 0);
|
||||
`;
|
||||
const result = parseGsapScript(script);
|
||||
expect(result.animations).toHaveLength(1);
|
||||
expect(result.animations[0].properties).not.toHaveProperty("onComplete");
|
||||
expect(result.animations[0].extras).toBeUndefined();
|
||||
});
|
||||
|
||||
it("delay is not included in properties (BUILTIN_VAR_KEYS)", () => {
|
||||
const script = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#el", { opacity: 1, duration: 0.5, delay: 0.2 }, 0);
|
||||
`;
|
||||
const result = parseGsapScript(script);
|
||||
expect(result.animations[0].properties).not.toHaveProperty("delay");
|
||||
expect(result.animations[0].properties).not.toHaveProperty("duration");
|
||||
});
|
||||
|
||||
it("percentage string values in properties survive", () => {
|
||||
const script = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#el", { width: "50%", opacity: 1, duration: 1 }, 0);
|
||||
`;
|
||||
const result = parseGsapScript(script);
|
||||
expect(result.animations[0].properties.width).toBe("50%");
|
||||
expect(result.animations[0].properties.opacity).toBe(1);
|
||||
});
|
||||
|
||||
it("scope resolution: binary expression with one unresolvable side", () => {
|
||||
expectRawWithResolvable(
|
||||
`
|
||||
const BASE = 100;
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#el", { x: BASE + unknownVar, y: BASE * 2, duration: 1 }, 0);
|
||||
`,
|
||||
"x",
|
||||
"y",
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
it("negative position in ID generation", () => {
|
||||
const script = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#el", { x: 100, duration: 1 }, -2.5);
|
||||
`;
|
||||
const result = parseGsapScript(script);
|
||||
// ID uses Math.round(position * 1000) for numeric positions
|
||||
expect(result.animations[0].id).toBe("#el-to--2500-position");
|
||||
});
|
||||
|
||||
it("fromTo with no position arg defaults to 0", () => {
|
||||
expectSingleAnimPosition(
|
||||
`
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.fromTo("#el", { opacity: 0 }, { opacity: 1, duration: 1 });
|
||||
`,
|
||||
0,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
// fallow-ignore-file dead-code
|
||||
import { expect } from "vitest";
|
||||
import {
|
||||
parseGsapScript,
|
||||
serializeGsapAnimations,
|
||||
convertToKeyframesInScript,
|
||||
} from "./gsapParser.js";
|
||||
import type { GsapAnimation, GsapPercentageKeyframe } from "./gsapParser.js";
|
||||
|
||||
/**
|
||||
* Parse a script and serialize the result, returning both the parsed output
|
||||
* and the serialized string for assertion. Shared across gsapParser.test.ts
|
||||
* and gsapParser.stress.test.ts.
|
||||
*/
|
||||
export function parseAndSerialize(script: string) {
|
||||
const parsed = parseGsapScript(script);
|
||||
const serialized = serializeGsapAnimations(parsed.animations, parsed.timelineVar, {
|
||||
preamble: parsed.preamble,
|
||||
postamble: parsed.postamble,
|
||||
});
|
||||
return { parsed, serialized };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a script expecting exactly one animation, and return it directly.
|
||||
*/
|
||||
export function parseSingleAnimation(script: string): GsapAnimation {
|
||||
const result = parseGsapScript(script);
|
||||
expect(result.animations).toHaveLength(1);
|
||||
return result.animations[0]!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that a parsed animation's stagger extra exists and contains
|
||||
* the expected substrings (as a __raw: prefixed string).
|
||||
*/
|
||||
export function expectStaggerRaw(anim: GsapAnimation, ...expectedSubstrings: string[]): void {
|
||||
expect(anim.extras).toBeDefined();
|
||||
expect(anim.extras!.stagger).toBeDefined();
|
||||
const stagger = String(anim.extras!.stagger);
|
||||
expect(stagger.startsWith("__raw:")).toBe(true);
|
||||
for (const sub of expectedSubstrings) {
|
||||
expect(stagger).toContain(sub);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert a single keyframe's percentage, properties, and optional ease.
|
||||
*/
|
||||
export function expectKeyframe(
|
||||
kf: GsapPercentageKeyframe,
|
||||
percentage: number,
|
||||
properties: Record<string, number | string>,
|
||||
ease?: string,
|
||||
): void {
|
||||
expect(kf.percentage).toBe(percentage);
|
||||
for (const [key, value] of Object.entries(properties)) {
|
||||
expect(kf.properties[key]).toBe(value);
|
||||
}
|
||||
if (ease !== undefined) {
|
||||
expect(kf.ease).toBe(ease);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that an animation has a defined keyframes block with the expected format
|
||||
* and count, and return the keyframes array for further assertions.
|
||||
*/
|
||||
export function expectKeyframesFormat(
|
||||
anim: GsapAnimation,
|
||||
format: string,
|
||||
count: number,
|
||||
): GsapPercentageKeyframe[] {
|
||||
expect(anim.keyframes).toBeDefined();
|
||||
expect(anim.keyframes!.format).toBe(format);
|
||||
expect(anim.keyframes!.keyframes).toHaveLength(count);
|
||||
return anim.keyframes!.keyframes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a script expecting one animation, assert that `rawProp` is a __raw: string
|
||||
* and `resolvableProp` has the expected value.
|
||||
*/
|
||||
export function expectRawWithResolvable(
|
||||
script: string,
|
||||
rawProp: string,
|
||||
resolvableProp: string,
|
||||
resolvableValue: number | string,
|
||||
): void {
|
||||
const anim = parseSingleAnimation(script);
|
||||
const val = anim.properties[rawProp];
|
||||
expect(typeof val === "string" && val.startsWith("__raw:")).toBe(true);
|
||||
expect(anim.properties[resolvableProp]).toBe(resolvableValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a script expecting one animation, assert that `position` matches the expected value.
|
||||
*/
|
||||
export function expectSingleAnimPosition(script: string, position: number): void {
|
||||
const anim = parseSingleAnimation(script);
|
||||
expect(anim.position).toBe(position);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a script, get the first animation id, run convertToKeyframesInScript,
|
||||
* reparse, and return the first animation for assertion.
|
||||
*/
|
||||
export function convertAndReparse(
|
||||
script: string,
|
||||
runtimeValues?: Record<string, number | string>,
|
||||
): GsapAnimation {
|
||||
const id = parseSingleAnimation(script).id;
|
||||
const updated = convertToKeyframesInScript(script, id, runtimeValues);
|
||||
return parseSingleAnimation(updated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a script, return the first animation and run a split-related reparse.
|
||||
* Asserts the reparse result has exactly `expectedCount` animations and returns
|
||||
* the selector of the first animation.
|
||||
*/
|
||||
export function parseSplitAndAssert(
|
||||
script: string,
|
||||
splitFn: (s: string) => string,
|
||||
expectedCount: number,
|
||||
): string[] {
|
||||
const result = splitFn(script);
|
||||
const parsed = parseGsapScript(result);
|
||||
expect(parsed.animations).toHaveLength(expectedCount);
|
||||
return parsed.animations.map((a) => a.targetSelector);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* U3: end-to-end resolution of computed timelines (helpers, loops) through the
|
||||
* read parser — true positions, motionPath arcs, and provenance — plus
|
||||
* regression coverage that literal-position compositions are unchanged.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseGsapScriptAcorn, editabilityForProvenance } from "./gsapParserAcorn.js";
|
||||
|
||||
describe("editabilityForProvenance", () => {
|
||||
it("maps provenance kinds to an editing strategy", () => {
|
||||
expect(editabilityForProvenance(undefined)).toBe("direct");
|
||||
expect(editabilityForProvenance({ kind: "literal" })).toBe("direct");
|
||||
expect(editabilityForProvenance({ kind: "helper", fn: "addCycle", callSite: 1 })).toBe(
|
||||
"unroll",
|
||||
);
|
||||
expect(editabilityForProvenance({ kind: "loop", callSite: 1, iteration: 0 })).toBe("unroll");
|
||||
expect(editabilityForProvenance({ kind: "runtime-dynamic" })).toBe("source");
|
||||
});
|
||||
});
|
||||
|
||||
const start = (a: { resolvedStart?: number }): number | undefined => a.resolvedStart;
|
||||
|
||||
describe("parseGsapScriptAcorn — computed timelines", () => {
|
||||
it("resolves an add-to-basket helper called twice (the reported case)", () => {
|
||||
const script = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
const DX = 852, DY = -322, FLY_SCALE = 56 / 160;
|
||||
tl.from("#product", { opacity: 0, scale: 0.8, duration: 0.5 }, 0.1);
|
||||
function addCycle(at, path, curviness, spin) {
|
||||
tl.to("#product", { y: -15, scale: 1.05, duration: 0.15 }, at + 0.15);
|
||||
tl.to("#product", { motionPath: { path, curviness }, scale: FLY_SCALE, rotation: spin, duration: 0.55 }, at + 0.3);
|
||||
tl.to("#product", { opacity: 0, duration: 0.08 }, at + 0.78);
|
||||
}
|
||||
addCycle(1.0, [{x:0,y:-15},{x:180,y:-300},{x:520,y:-360},{x:DX,y:DY}], 2, 18);
|
||||
addCycle(3.6, [{x:0,y:-15},{x:-120,y:-220},{x:350,y:-380},{x:DX,y:DY}], 2.5, -22);
|
||||
`;
|
||||
const { animations } = parseGsapScriptAcorn(script);
|
||||
|
||||
// 1 entrance + 3 body tweens × 2 cycles = 7 (was 4 before inlining).
|
||||
expect(animations).toHaveLength(7);
|
||||
|
||||
// Entrance keeps its literal position and has no provenance.
|
||||
expect(start(animations[0]!)).toBeCloseTo(0.1);
|
||||
expect(animations[0]!.provenance).toBeUndefined();
|
||||
|
||||
// Cycle tweens land at their true absolute times, in order.
|
||||
expect(animations.slice(1).map(start)).toEqual([1.15, 1.3, 1.78, 3.75, 3.9, 4.38]);
|
||||
|
||||
// Both flight tweens are recognized as arcs and tagged with helper provenance.
|
||||
const arcs = animations.filter((a) => a.arcPath?.enabled);
|
||||
expect(arcs).toHaveLength(2);
|
||||
expect(arcs.map((a) => a.provenance?.fn)).toEqual(["addCycle", "addCycle"]);
|
||||
expect(arcs.map((a) => a.provenance?.callSite)).toEqual([1, 2]);
|
||||
// 4 waypoints ⇒ Arc Motion's ">= 2 position keyframes" gate passes.
|
||||
expect(arcs[0]!.keyframes?.keyframes).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("resolves a bounded for-loop", () => {
|
||||
const { animations } = parseGsapScriptAcorn(`
|
||||
const tl = gsap.timeline();
|
||||
for (let i = 0; i < 3; i++) { tl.to("#x", { x: 100, duration: 0.5 }, i * 0.5); }
|
||||
`);
|
||||
expect(animations).toHaveLength(3);
|
||||
expect(animations.map(start)).toEqual([0, 0.5, 1]);
|
||||
expect(animations.map((a) => a.provenance?.kind)).toEqual(["loop", "loop", "loop"]);
|
||||
});
|
||||
|
||||
it("leaves a literal-position composition unchanged (regression)", () => {
|
||||
const { animations } = parseGsapScriptAcorn(`
|
||||
const tl = gsap.timeline();
|
||||
tl.from("#a", { opacity: 0, duration: 0.5 }, 0.1);
|
||||
tl.to("#b", { x: 50, duration: 0.4 }, 1.0);
|
||||
`);
|
||||
expect(animations).toHaveLength(2);
|
||||
expect(animations.map(start)).toEqual([0.1, 1.0]);
|
||||
expect(animations.every((a) => a.provenance === undefined)).toBe(true);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,75 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseGsapScriptAcorn } from "./gsapParserAcorn.js";
|
||||
|
||||
// U1+U2: the editor must read timelines authored inline as
|
||||
// `window.__timelines["id"] = gsap.timeline()` — not just the canonical
|
||||
// `const tl = gsap.timeline(); window.__timelines[id] = tl` form.
|
||||
|
||||
const wrap = (decl: string, tweens: string) =>
|
||||
`window.__timelines = window.__timelines || {};\n${decl}\n${tweens}`;
|
||||
|
||||
describe("inline timeline assignment — read", () => {
|
||||
it("reads tweens from a double-quoted inline timeline", () => {
|
||||
const src = wrap(
|
||||
`window.__timelines["scene"] = gsap.timeline({ paused: true });`,
|
||||
`window.__timelines["scene"].to("#a", { x: 100, duration: 1 }, 0);\n` +
|
||||
`window.__timelines["scene"].to("#b", { y: 50, duration: 1 }, 0.5);`,
|
||||
);
|
||||
const parsed = parseGsapScriptAcorn(src);
|
||||
expect(parsed.unsupportedTimelinePattern).toBeFalsy();
|
||||
expect(parsed.animations).toHaveLength(2);
|
||||
expect(parsed.animations[0]!.targetSelector).toBe("#a");
|
||||
expect(parsed.animations[1]!.targetSelector).toBe("#b");
|
||||
});
|
||||
|
||||
it("reads a single-quoted inline timeline", () => {
|
||||
const src = wrap(
|
||||
`window.__timelines['scene'] = gsap.timeline();`,
|
||||
`window.__timelines['scene'].to('#a', { x: 10, duration: 1 }, 0);`,
|
||||
);
|
||||
const parsed = parseGsapScriptAcorn(src);
|
||||
expect(parsed.unsupportedTimelinePattern).toBeFalsy();
|
||||
expect(parsed.animations).toHaveLength(1);
|
||||
expect(parsed.animations[0]!.targetSelector).toBe("#a");
|
||||
});
|
||||
|
||||
it("reads a static dot-access inline timeline", () => {
|
||||
const src = wrap(
|
||||
`window.__timelines.scene = gsap.timeline();`,
|
||||
`window.__timelines.scene.to("#a", { x: 10, duration: 1 }, 0);`,
|
||||
);
|
||||
const parsed = parseGsapScriptAcorn(src);
|
||||
expect(parsed.unsupportedTimelinePattern).toBeFalsy();
|
||||
expect(parsed.animations).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("flags a computed-key timeline as unsupported (cannot statically resolve)", () => {
|
||||
const src = wrap(
|
||||
`const id = "scene";\nwindow.__timelines[id] = gsap.timeline();`,
|
||||
`window.__timelines[id].to("#a", { x: 10, duration: 1 }, 0);`,
|
||||
);
|
||||
const parsed = parseGsapScriptAcorn(src);
|
||||
expect(parsed.unsupportedTimelinePattern).toBe(true);
|
||||
});
|
||||
|
||||
it("does not cross-attribute tweens of a different member slot", () => {
|
||||
const src = wrap(
|
||||
`window.__timelines["a"] = gsap.timeline();\nwindow.__timelines["b"] = gsap.timeline();`,
|
||||
`window.__timelines["a"].to("#a", { x: 1, duration: 1 }, 0);\n` +
|
||||
`window.__timelines["b"].to("#b", { x: 2, duration: 1 }, 0);`,
|
||||
);
|
||||
const parsed = parseGsapScriptAcorn(src);
|
||||
// First detected timeline is "a"; only its tween should be attributed here.
|
||||
expect(parsed.multipleTimelines).toBe(true);
|
||||
expect(parsed.animations.some((a) => a.targetSelector === "#a")).toBe(true);
|
||||
expect(parsed.animations.every((a) => a.targetSelector !== "#b")).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves the canonical const form working", () => {
|
||||
const src = `const tl = gsap.timeline();\nwindow.__timelines["scene"] = tl;\ntl.to("#a", { x: 5, duration: 1 }, 0);`;
|
||||
const parsed = parseGsapScriptAcorn(src);
|
||||
expect(parsed.unsupportedTimelinePattern).toBeFalsy();
|
||||
expect(parsed.animations).toHaveLength(1);
|
||||
expect(parsed.timelineVar).toBe("tl");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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";
|
||||
@@ -0,0 +1,604 @@
|
||||
/**
|
||||
* Recast-free GSAP helpers: serialization, keyframe<->animation conversion,
|
||||
* validation, and shared types.
|
||||
*
|
||||
* This module MUST NOT import recast / @babel/parser. It is part of the
|
||||
* isomorphic core layer that the barrel and browser code depend on. AST
|
||||
* parsing of GSAP source lives in the Node-only `./gsapParser` module.
|
||||
*/
|
||||
import type { Keyframe, KeyframeProperties, ValidationResult } from "./types.js";
|
||||
import type { PropertyGroupName } from "./gsapConstants";
|
||||
|
||||
export type GsapMethod = "set" | "to" | "from" | "fromTo";
|
||||
|
||||
/** How a tween was constructed in source — drives display classification and editability. */
|
||||
export type GsapProvenanceKind = "literal" | "helper" | "loop" | "runtime-dynamic";
|
||||
|
||||
/**
|
||||
* Origin of a parsed tween. `literal` tweens map 1:1 to a source call and edit
|
||||
* directly; `helper`/`loop` tweens are expanded from a reused construct (unroll
|
||||
* to edit); `runtime-dynamic` tweens come from live introspection (override to
|
||||
* edit). Absent provenance is treated as `literal`.
|
||||
*/
|
||||
export interface GsapProvenance {
|
||||
kind: GsapProvenanceKind;
|
||||
/** Helper function name (kind === "helper"). */
|
||||
fn?: string;
|
||||
/** 1-based ordinal of the originating call site / loop construct in source order. */
|
||||
callSite?: number;
|
||||
/** 0-based iteration index (kind === "loop"). */
|
||||
iteration?: number;
|
||||
/** Source offset [start, end] of the originating call/loop, when known. */
|
||||
sourceRange?: [number, number];
|
||||
}
|
||||
|
||||
/** How a tween's keyframes can be edited, derived from its provenance. */
|
||||
export type KeyframeEditability = "direct" | "unroll" | "source";
|
||||
|
||||
/**
|
||||
* Map provenance to an editing strategy:
|
||||
* - `direct` — literal tween, maps 1:1 to source; edit in place.
|
||||
* - `unroll` — helper/loop expansion; unroll to literal tweens, then edit.
|
||||
* - `source` — runtime-dynamic value; not statically editable, edit the code.
|
||||
*/
|
||||
export function editabilityForProvenance(provenance?: GsapProvenance): KeyframeEditability {
|
||||
if (!provenance || provenance.kind === "literal") return "direct";
|
||||
if (provenance.kind === "runtime-dynamic") return "source";
|
||||
return "unroll";
|
||||
}
|
||||
|
||||
export interface GsapAnimation {
|
||||
id: string;
|
||||
targetSelector: string;
|
||||
method: GsapMethod;
|
||||
position: number | string;
|
||||
properties: Record<string, number | string>;
|
||||
fromProperties?: Record<string, number | string>;
|
||||
duration?: number;
|
||||
ease?: string;
|
||||
/** Non-editable GSAP config (stagger, yoyo, repeat, etc.) preserved for round-trips. */
|
||||
extras?: Record<string, unknown>;
|
||||
/** Native GSAP keyframes data — present when the tween uses keyframes: { ... }. */
|
||||
keyframes?: GsapKeyframesData;
|
||||
/** Arc motion path config — present when the tween uses motionPath for curved position interpolation. */
|
||||
arcPath?: ArcPathConfig;
|
||||
/** True when the tween has a `keyframes` property that couldn't be statically resolved (dynamic). */
|
||||
hasUnresolvedKeyframes?: boolean;
|
||||
/** True when the tween's target selector couldn't be statically resolved (dynamic). */
|
||||
hasUnresolvedSelector?: boolean;
|
||||
/** Absolute start time computed by walking the timeline chain (handles +=, -=, <, >, labels). */
|
||||
resolvedStart?: number;
|
||||
/** True when no position arg was authored — the tween is sequentially placed by GSAP. */
|
||||
implicitPosition?: boolean;
|
||||
/** Which property group this tween belongs to (position, scale, size, rotation, visual, other).
|
||||
* Undefined for legacy mixed tweens that bundle multiple groups. */
|
||||
propertyGroup?: PropertyGroupName;
|
||||
/** True for a base `gsap.set(...)` (a static hold that runs immediately, OFF the
|
||||
* timeline) rather than `tl.set(...)`. Carries no timeline position and shows no
|
||||
* keyframe marker — used to persist a static value (e.g. a 3D transform) without
|
||||
* introducing a 0% keyframe. */
|
||||
global?: boolean;
|
||||
/** How this tween was constructed in source. Absent ⇒ literal. */
|
||||
provenance?: GsapProvenance;
|
||||
}
|
||||
|
||||
export interface GsapPercentageKeyframe {
|
||||
percentage: number;
|
||||
properties: Record<string, number | string>;
|
||||
ease?: string;
|
||||
}
|
||||
|
||||
export type GsapKeyframeFormat = "percentage" | "object-array" | "simple-array";
|
||||
|
||||
export interface GsapKeyframesData {
|
||||
format: GsapKeyframeFormat;
|
||||
keyframes: GsapPercentageKeyframe[];
|
||||
ease?: string;
|
||||
easeEach?: string;
|
||||
}
|
||||
|
||||
export interface ArcPathSegment {
|
||||
curviness: number;
|
||||
cp1?: { x: number; y: number };
|
||||
cp2?: { x: number; y: number };
|
||||
}
|
||||
|
||||
export interface ArcPathConfig {
|
||||
enabled: boolean;
|
||||
autoRotate: boolean | number;
|
||||
segments: ArcPathSegment[];
|
||||
}
|
||||
|
||||
export interface MotionPathShape {
|
||||
arcPath: ArcPathConfig;
|
||||
waypoints: Array<{ x: number; y: number }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build arcPath segments + waypoints from resolved path coordinates. Shared by
|
||||
* the AST parser (coords from literal nodes) and the runtime scanner (coords
|
||||
* from a live `vars.motionPath`), so both produce identical arc config.
|
||||
*/
|
||||
export function buildArcPath(
|
||||
coords: Array<{ x: number; y: number }>,
|
||||
curviness: number,
|
||||
autoRotate: boolean | number,
|
||||
isCubic: boolean,
|
||||
): MotionPathShape | undefined {
|
||||
const first = coords[0];
|
||||
if (coords.length < 2 || !first) return undefined;
|
||||
const segments: ArcPathSegment[] = [];
|
||||
let waypoints: Array<{ x: number; y: number }>;
|
||||
if (isCubic && coords.length >= 4) {
|
||||
// coords are [anchor, cp1, cp2, anchor, cp1, cp2, anchor, ...].
|
||||
waypoints = [first];
|
||||
for (let i = 1; i + 2 < coords.length; i += 3) {
|
||||
const cp1 = coords[i];
|
||||
const cp2 = coords[i + 1];
|
||||
const anchor = coords[i + 2];
|
||||
if (!cp1 || !cp2 || !anchor) continue;
|
||||
waypoints.push(anchor);
|
||||
segments.push({ curviness, cp1, cp2 });
|
||||
}
|
||||
} else {
|
||||
waypoints = coords;
|
||||
for (let i = 0; i < waypoints.length - 1; i++) segments.push({ curviness });
|
||||
}
|
||||
return { arcPath: { enabled: true, autoRotate, segments }, waypoints };
|
||||
}
|
||||
|
||||
export interface ParsedGsap {
|
||||
animations: GsapAnimation[];
|
||||
timelineVar: string;
|
||||
preamble: string;
|
||||
postamble: string;
|
||||
multipleTimelines?: boolean;
|
||||
unsupportedTimelinePattern?: boolean;
|
||||
}
|
||||
|
||||
export { SUPPORTED_PROPS, SUPPORTED_EASES } from "./gsapConstants";
|
||||
|
||||
// ── Split-animation types (used by gsapWriterAcorn) ─────────────────────────
|
||||
|
||||
export interface SplitAnimationsOptions {
|
||||
originalId: string;
|
||||
newId: string;
|
||||
splitTime: number;
|
||||
elementStart: number;
|
||||
elementDuration: number;
|
||||
}
|
||||
|
||||
export interface SplitAnimationsResult {
|
||||
script: string;
|
||||
/** Non-ID-selector animations that the engine cannot safely retarget. */
|
||||
skippedSelectors: string[];
|
||||
}
|
||||
|
||||
// ── Serialization ───────────────────────────────────────────────────────────
|
||||
|
||||
export function serializeGsapAnimations(
|
||||
animations: GsapAnimation[],
|
||||
timelineVar = "tl",
|
||||
options?: { includeMediaSync?: boolean; preamble?: string; postamble?: string },
|
||||
): string {
|
||||
const sorted = [...animations].sort((a, b) => {
|
||||
const aNum =
|
||||
a.resolvedStart ?? (typeof a.position === "number" ? a.position : Number.MAX_SAFE_INTEGER);
|
||||
const bNum =
|
||||
b.resolvedStart ?? (typeof b.position === "number" ? b.position : Number.MAX_SAFE_INTEGER);
|
||||
return aNum - bNum;
|
||||
});
|
||||
// fallow-ignore-next-line complexity
|
||||
const lines = sorted.map((anim) => {
|
||||
const selector = `"${anim.targetSelector}"`;
|
||||
const props: Record<string, number | string> = { ...anim.properties };
|
||||
if (anim.duration !== undefined) props.duration = anim.duration;
|
||||
if (anim.ease) props.ease = anim.ease;
|
||||
let propsStr = serializeObject(props);
|
||||
if (anim.extras && Object.keys(anim.extras).length > 0) {
|
||||
const extrasStr = serializeExtras(anim.extras);
|
||||
if (Object.keys(props).length === 0) {
|
||||
propsStr = `{ ${extrasStr} }`;
|
||||
} else {
|
||||
// Insert extras before the closing brace
|
||||
propsStr = propsStr.slice(0, -2) + `, ${extrasStr} }`;
|
||||
}
|
||||
}
|
||||
const posStr = typeof anim.position === "string" ? `"${anim.position}"` : anim.position;
|
||||
switch (anim.method) {
|
||||
case "set":
|
||||
// A global set is a base `gsap.set` — off the timeline, no position arg.
|
||||
return anim.global
|
||||
? ` gsap.set(${selector}, ${propsStr});`
|
||||
: ` ${timelineVar}.set(${selector}, ${propsStr}, ${posStr});`;
|
||||
case "to":
|
||||
return ` ${timelineVar}.to(${selector}, ${propsStr}, ${posStr});`;
|
||||
case "from":
|
||||
return ` ${timelineVar}.from(${selector}, ${propsStr}, ${posStr});`;
|
||||
case "fromTo": {
|
||||
const fromStr = serializeObject(anim.fromProperties || {});
|
||||
return ` ${timelineVar}.fromTo(${selector}, ${fromStr}, ${propsStr}, ${posStr});`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let mediaSync = "";
|
||||
if (options?.includeMediaSync) {
|
||||
mediaSync = `
|
||||
${timelineVar}.eventCallback("onUpdate", function() {
|
||||
const time = ${timelineVar}.time();
|
||||
document.querySelectorAll("video[data-start], audio[data-start]").forEach(function(media) {
|
||||
const start = parseFloat(media.dataset.start);
|
||||
const end = parseFloat(media.dataset.end) || Infinity;
|
||||
const mediaTime = time - start;
|
||||
if (time >= start && time < end) {
|
||||
if (Math.abs(media.currentTime - mediaTime) > 0.1) {
|
||||
media.currentTime = mediaTime;
|
||||
}
|
||||
if (media.paused && !${timelineVar}.paused()) {
|
||||
media.play().catch(function() {});
|
||||
}
|
||||
} else if (!media.paused) {
|
||||
media.pause();
|
||||
}
|
||||
});
|
||||
});`;
|
||||
}
|
||||
|
||||
const preamble = options?.preamble || `const ${timelineVar} = gsap.timeline({ paused: true });`;
|
||||
const postamble = options?.postamble ? `\n ${options.postamble}` : "";
|
||||
|
||||
return `
|
||||
${preamble}
|
||||
${lines.join("\n")}${mediaSync}${postamble}
|
||||
`;
|
||||
}
|
||||
|
||||
export function serializeValue(value: unknown): string {
|
||||
if (typeof value === "string" && value.startsWith("__raw:")) {
|
||||
return value.slice(6);
|
||||
}
|
||||
if (typeof value === "string") return JSON.stringify(value);
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function safeJsKey(key: string): string {
|
||||
return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key) ? key : JSON.stringify(key);
|
||||
}
|
||||
|
||||
function serializeObject(obj: Record<string, number | string>): string {
|
||||
const entries = Object.entries(obj).map(([key, value]) => {
|
||||
return `${safeJsKey(key)}: ${serializeValue(value)}`;
|
||||
});
|
||||
return `{ ${entries.join(", ")} }`;
|
||||
}
|
||||
|
||||
function serializeExtras(extras: Record<string, unknown>): string {
|
||||
return Object.entries(extras)
|
||||
.map(([key, value]) => {
|
||||
return `${safeJsKey(key)}: ${serializeValue(value)}`;
|
||||
})
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
// ── Element filtering ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Filter animations to those targeting `#<elementId>` (id-only match). For the
|
||||
* studio panel's id-OR-selector matching, see `getAnimationsForElement` in
|
||||
* `useGsapTweenCache.ts` — distinct on purpose, hence the distinct name.
|
||||
*/
|
||||
export function getAnimationsForElementId(
|
||||
animations: GsapAnimation[],
|
||||
elementId: string,
|
||||
): GsapAnimation[] {
|
||||
const selector = `#${elementId}`;
|
||||
return animations.filter((a) => a.targetSelector === selector);
|
||||
}
|
||||
|
||||
// ── Validation (regex-based, no AST needed) ─────────────────────────────────
|
||||
|
||||
const FORBIDDEN_GSAP_PATTERNS: Array<{ pattern: RegExp; message: string }> = [
|
||||
{ pattern: /\.call\s*\(/, message: "call() method not allowed" },
|
||||
{ pattern: /\.add\s*\(/, message: "add() method not allowed" },
|
||||
{ pattern: /\.addPause\s*\(/, message: "addPause() method not allowed" },
|
||||
{ pattern: /gsap\.registerEffect\s*\(/, message: "registerEffect() not allowed" },
|
||||
{ pattern: /ScrollTrigger/, message: "ScrollTrigger not allowed" },
|
||||
{ pattern: /onComplete\s*:/, message: "onComplete callback not allowed" },
|
||||
{ pattern: /onUpdate\s*:/, message: "onUpdate callback not allowed" },
|
||||
{ pattern: /onStart\s*:/, message: "onStart callback not allowed" },
|
||||
{ pattern: /onRepeat\s*:/, message: "onRepeat callback not allowed" },
|
||||
{ pattern: /onReverseComplete\s*:/, message: "onReverseComplete callback not allowed" },
|
||||
{ pattern: /repeat\s*:\s*-1/, message: "Infinite repeat (repeat: -1) not allowed" },
|
||||
{ pattern: /Math\.random\s*\(/, message: "Random values (Math.random) not allowed" },
|
||||
{ pattern: /Date\.now\s*\(/, message: "Date-dependent values (Date.now) not allowed" },
|
||||
{ pattern: /new\s+Date\s*\(/, message: "Date constructor not allowed" },
|
||||
{ pattern: /setTimeout\s*\(/, message: "setTimeout not allowed" },
|
||||
{ pattern: /setInterval\s*\(/, message: "setInterval not allowed" },
|
||||
{ pattern: /requestAnimationFrame\s*\(/, message: "requestAnimationFrame not allowed" },
|
||||
];
|
||||
|
||||
export function validateCompositionGsap(script: string): ValidationResult {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
for (const { pattern, message } of FORBIDDEN_GSAP_PATTERNS) {
|
||||
if (pattern.test(script)) errors.push(message);
|
||||
}
|
||||
if (/yoyo\s*:\s*true/.test(script)) {
|
||||
warnings.push("yoyo animations may behave unexpectedly when scrubbing");
|
||||
}
|
||||
if (/stagger\s*:/.test(script)) {
|
||||
warnings.push("stagger animations may not serialize correctly");
|
||||
}
|
||||
return { valid: errors.length === 0, errors, warnings };
|
||||
}
|
||||
|
||||
// ── Keyframe Conversion Helpers ─────────────────────────────────────────────
|
||||
|
||||
export function keyframesToGsapAnimations(
|
||||
elementId: string,
|
||||
keyframes: Keyframe[],
|
||||
elementStartTime: number,
|
||||
base?: { x?: number; y?: number; scale?: number },
|
||||
): GsapAnimation[] {
|
||||
const sorted = [...keyframes].sort((a, b) => a.time - b.time);
|
||||
const animations: GsapAnimation[] = [];
|
||||
const baseX = base?.x ?? 0;
|
||||
const baseY = base?.y ?? 0;
|
||||
const baseScale = base?.scale ?? 1;
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
sorted.forEach((kf, i) => {
|
||||
const absoluteTime = elementStartTime + kf.time;
|
||||
const isFirst = i === 0;
|
||||
const prevKf = i > 0 ? sorted[i - 1] : null;
|
||||
const duration = prevKf ? kf.time - prevKf.time : undefined;
|
||||
const position = prevKf ? elementStartTime + prevKf.time : absoluteTime;
|
||||
|
||||
const properties: Record<string, number | string> = {};
|
||||
for (const [key, value] of Object.entries(kf.properties)) {
|
||||
if (typeof value !== "number") continue;
|
||||
if (key === "x") properties.x = baseX + value;
|
||||
else if (key === "y") properties.y = baseY + value;
|
||||
else if (key === "scale") properties.scale = baseScale * value;
|
||||
else properties[key] = value;
|
||||
}
|
||||
|
||||
animations.push({
|
||||
id: `${elementId}-kf-${kf.id}`,
|
||||
targetSelector: `#${elementId}`,
|
||||
method: isFirst ? "set" : "to",
|
||||
position,
|
||||
properties,
|
||||
duration: isFirst ? undefined : duration,
|
||||
ease: kf.ease,
|
||||
});
|
||||
});
|
||||
|
||||
return animations;
|
||||
}
|
||||
|
||||
export function gsapAnimationsToKeyframes(
|
||||
animations: GsapAnimation[],
|
||||
elementStartTime: number,
|
||||
options?: {
|
||||
baseX?: number;
|
||||
baseY?: number;
|
||||
baseScale?: number;
|
||||
clampTimeToZero?: boolean;
|
||||
skipBaseSet?: boolean;
|
||||
},
|
||||
): Keyframe[] {
|
||||
const validMethods: GsapMethod[] = ["set", "to", "from", "fromTo"];
|
||||
const baseX = options?.baseX ?? 0;
|
||||
const baseY = options?.baseY ?? 0;
|
||||
const baseScale = options?.baseScale ?? 1;
|
||||
const clampTimeToZero = options?.clampTimeToZero ?? true;
|
||||
const skipBaseSet = options?.skipBaseSet ?? false;
|
||||
const baseTimeEpsilon = 0.001;
|
||||
const baseValueEpsilon = 0.00001;
|
||||
|
||||
return (
|
||||
animations
|
||||
.filter(
|
||||
(a): a is GsapAnimation & { position: number } =>
|
||||
validMethods.includes(a.method) && typeof a.position === "number",
|
||||
)
|
||||
// fallow-ignore-next-line complexity
|
||||
.map((a) => {
|
||||
const relativeTimeRaw = a.position - elementStartTime;
|
||||
const time = clampTimeToZero ? Math.max(0, relativeTimeRaw) : relativeTimeRaw;
|
||||
|
||||
const properties: Partial<KeyframeProperties> = {};
|
||||
for (const [key, value] of Object.entries(a.properties)) {
|
||||
if (typeof value !== "number") continue;
|
||||
if (key === "x") properties.x = value - baseX;
|
||||
else if (key === "y") properties.y = value - baseY;
|
||||
else if (key === "scale") {
|
||||
properties.scale = baseScale !== 0 ? value / baseScale : value;
|
||||
} else {
|
||||
(properties as Record<string, number>)[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
skipBaseSet &&
|
||||
a.method === "set" &&
|
||||
time < baseTimeEpsilon &&
|
||||
Object.values(properties).every(
|
||||
(v) => typeof v === "number" && Math.abs(v) < baseValueEpsilon,
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: a.id.replace(/^.*-kf-/, ""),
|
||||
time,
|
||||
properties: properties as KeyframeProperties,
|
||||
ease: a.ease,
|
||||
};
|
||||
})
|
||||
.filter((kf): kf is NonNullable<typeof kf> => kf !== null)
|
||||
);
|
||||
}
|
||||
|
||||
// ── Keyframe-conversion transforms (pure; shared by recast + acorn writers) ────
|
||||
|
||||
/**
|
||||
* CSS identity values for properties whose "rest" state isn't 0 — used to
|
||||
* synthesize the missing endpoint when converting a flat tween to keyframes.
|
||||
*/
|
||||
const CSS_IDENTITY: Record<string, number> = {
|
||||
opacity: 1,
|
||||
autoAlpha: 1,
|
||||
scale: 1,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
};
|
||||
|
||||
function cssIdentityValue(prop: string): number {
|
||||
return CSS_IDENTITY[prop] ?? 0;
|
||||
}
|
||||
|
||||
/** Build the identity-endpoint map for a flat tween's properties. */
|
||||
function buildIdentityMap(props: Record<string, number | string>): Record<string, number | string> {
|
||||
const identity: Record<string, number | string> = {};
|
||||
for (const [key, val] of Object.entries(props)) {
|
||||
if (val != null) identity[key] = typeof val === "number" ? cssIdentityValue(key) : val;
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the 0% (from) and 100% (to) property maps for a tween being
|
||||
* converted to percentage keyframes.
|
||||
*
|
||||
* @param resolvedFromValues — Despite the "from" in the name (historical), these
|
||||
* are runtime-captured DOM values that override the conversion endpoint:
|
||||
* - For to(): overrides fromProps (the 0% state / where the element is now).
|
||||
* - For from(): overrides toProps (the 100% state / where the element rests).
|
||||
* - For fromTo(): merges into toProps (the 100% endpoint the user is editing).
|
||||
*/
|
||||
export function resolveConversionProps(
|
||||
anim: GsapAnimation,
|
||||
resolvedFromValues?: Record<string, number | string>,
|
||||
): { fromProps: Record<string, number | string>; toProps: Record<string, number | string> } {
|
||||
if (anim.method === "set") {
|
||||
// A static hold becomes a keyframed `to` whose 0% and 100% both start at the
|
||||
// set's value — the visual is unchanged until the user edits a keyframe to
|
||||
// animate it. (The caller flips the call from `set` to `to` + adds a duration.)
|
||||
return { fromProps: { ...anim.properties }, toProps: { ...anim.properties } };
|
||||
}
|
||||
if (anim.method === "to") {
|
||||
const identity = buildIdentityMap(anim.properties);
|
||||
const fromProps = resolvedFromValues ? { ...identity, ...resolvedFromValues } : identity;
|
||||
return { fromProps, toProps: { ...anim.properties } };
|
||||
}
|
||||
if (anim.method === "from") {
|
||||
const identity = buildIdentityMap(anim.properties);
|
||||
const toProps = resolvedFromValues ? { ...identity, ...resolvedFromValues } : identity;
|
||||
return { fromProps: { ...anim.properties }, toProps };
|
||||
}
|
||||
// fromTo(fromVars, toVars): anim.fromProperties = fromVars (0% state),
|
||||
// anim.properties = toVars (100% state). resolvedFromValues contains the
|
||||
// current DOM position from a drag — it represents the NEW destination, so
|
||||
// it merges into toProps (the 100% endpoint the user is editing), NOT into
|
||||
// fromProps. This is intentional and not inverted.
|
||||
const toProps = resolvedFromValues
|
||||
? { ...anim.properties, ...resolvedFromValues }
|
||||
: { ...anim.properties };
|
||||
return { fromProps: { ...(anim.fromProperties ?? {}) }, toProps };
|
||||
}
|
||||
|
||||
// ── Arc path serialization helpers (shared by recast + acorn writers) ─────────
|
||||
|
||||
function numericXY(props: Record<string, number | string>): { x: number; y: number } | null {
|
||||
const vx = props.x;
|
||||
const vy = props.y;
|
||||
return typeof vx === "number" && typeof vy === "number" ? { x: vx, y: vy } : null;
|
||||
}
|
||||
|
||||
export function extractArcWaypoints(anim: GsapAnimation): Array<{ x: number; y: number }> {
|
||||
const keyframeWps = (anim.keyframes?.keyframes ?? [])
|
||||
.map((kf) => numericXY(kf.properties))
|
||||
.filter((pt): pt is { x: number; y: number } => pt !== null);
|
||||
if (keyframeWps.length >= 2) return keyframeWps;
|
||||
const propX = anim.properties.x;
|
||||
const propY = anim.properties.y;
|
||||
if (typeof propX !== "number" && typeof propY !== "number") return keyframeWps;
|
||||
const destX = typeof propX === "number" ? propX : 0;
|
||||
const destY = typeof propY === "number" ? propY : 0;
|
||||
return [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: destX, y: destY },
|
||||
];
|
||||
}
|
||||
|
||||
function autoRotateSuffix(autoRotate: boolean | number): string {
|
||||
if (autoRotate === true) return ", autoRotate: true";
|
||||
if (typeof autoRotate === "number") return `, autoRotate: ${autoRotate}`;
|
||||
return "";
|
||||
}
|
||||
|
||||
function cubicControlPoints(
|
||||
seg: ArcPathSegment,
|
||||
wp: { x: number; y: number },
|
||||
nextWp: { x: number; y: number },
|
||||
): string[] {
|
||||
if (seg.cp1 && seg.cp2) {
|
||||
return [`{x: ${seg.cp1.x}, y: ${seg.cp1.y}}`, `{x: ${seg.cp2.x}, y: ${seg.cp2.y}}`];
|
||||
}
|
||||
const dx = nextWp.x - wp.x;
|
||||
const dy = nextWp.y - wp.y;
|
||||
const c = seg.curviness ?? 1;
|
||||
return [
|
||||
`{x: ${wp.x + dx * 0.33}, y: ${wp.y + dy * 0.33 - c * Math.abs(dx) * 0.25}}`,
|
||||
`{x: ${wp.x + dx * 0.66}, y: ${wp.y + dy * 0.66 - c * Math.abs(dx) * 0.25}}`,
|
||||
];
|
||||
}
|
||||
|
||||
function buildCubicPathEntries(
|
||||
waypoints: Array<{ x: number; y: number }>,
|
||||
segments: ArcPathSegment[],
|
||||
): string[] {
|
||||
const first = waypoints[0];
|
||||
if (!first) return [];
|
||||
const entries = [`{x: ${first.x}, y: ${first.y}}`];
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const seg = segments[i];
|
||||
const wp = waypoints[i];
|
||||
const nextWp = waypoints[i + 1];
|
||||
if (!seg || !wp || !nextWp) continue;
|
||||
entries.push(...cubicControlPoints(seg, wp, nextWp));
|
||||
entries.push(`{x: ${nextWp.x}, y: ${nextWp.y}}`);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function buildMotionPathObjectCode(config: {
|
||||
waypoints: Array<{ x: number; y: number }>;
|
||||
segments: ArcPathSegment[];
|
||||
autoRotate: boolean | number;
|
||||
}): string {
|
||||
const { waypoints, segments, autoRotate } = config;
|
||||
const arSuffix = autoRotateSuffix(autoRotate);
|
||||
// GSAP's simple `path` array supports only ONE scalar `curviness` for the whole
|
||||
// path, so per-segment curviness can only be expressed in the cubic form (each
|
||||
// segment's curviness baked into its control points). Emit cubic when segments
|
||||
// carry explicit control points OR when their curviness values differ — the
|
||||
// simple branch would otherwise serialize only segments[0].curviness and drop
|
||||
// every other segment's curve.
|
||||
const hasExplicitCp = segments.some((s) => s.cp1 && s.cp2);
|
||||
const curvinessVaries = segments.some(
|
||||
(s) => (s.curviness ?? 1) !== (segments[0]?.curviness ?? 1),
|
||||
);
|
||||
if ((hasExplicitCp || curvinessVaries) && waypoints.length >= 2) {
|
||||
const pathStr = buildCubicPathEntries(waypoints, segments).join(", ");
|
||||
return `{ path: [${pathStr}], type: "cubic"${arSuffix} }`;
|
||||
}
|
||||
const pathEntries = waypoints.map((wp) => `{x: ${wp.x}, y: ${wp.y}}`);
|
||||
const curviness = segments[0]?.curviness ?? 1;
|
||||
const curvPart = curviness !== 1 ? `, curviness: ${curviness}` : "";
|
||||
return `{ path: [${pathEntries.join(", ")}]${curvPart}${arSuffix} }`;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { unrollComputedTimeline } from "./gsapUnroll.js";
|
||||
import { parseGsapScriptAcorn } from "./gsapParserAcorn.js";
|
||||
|
||||
const ARC_SCRIPT = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
const DX = 852, DY = -322, FLY_SCALE = 56 / 160;
|
||||
tl.from("#product", { opacity: 0, scale: 0.8, duration: 0.5 }, 0.1);
|
||||
function addCycle(at, path, curviness, spin) {
|
||||
tl.to("#product", { y: -15, scale: 1.05, duration: 0.15 }, at + 0.15);
|
||||
tl.to("#product", { motionPath: { path, curviness }, scale: FLY_SCALE, rotation: spin, duration: 0.55 }, at + 0.3);
|
||||
tl.to("#basket", { keyframes: { "0%": { y: 0 }, "50%": { y: -12 }, "100%": { y: 0 }, easeEach: "power2.out" }, duration: 0.5 }, at + 0.85);
|
||||
}
|
||||
addCycle(1.0, [{x:0,y:-15},{x:180,y:-300},{x:520,y:-360},{x:DX,y:DY}], 2, 18);
|
||||
addCycle(3.6, [{x:0,y:-15},{x:-120,y:-220},{x:350,y:-380},{x:DX,y:DY}], 2.5, -22);
|
||||
`;
|
||||
|
||||
const sig = (anims: ReturnType<typeof parseGsapScriptAcorn>["animations"]) =>
|
||||
anims
|
||||
.map(
|
||||
(a) =>
|
||||
`${a.targetSelector}|${a.method}|${a.resolvedStart}|arc:${a.arcPath?.segments.length ?? 0}|kf:${a.keyframes?.keyframes.length ?? 0}`,
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
describe("unrollComputedTimeline", () => {
|
||||
it("unrolls helper calls into literal tweens (visual no-op)", () => {
|
||||
const before = parseGsapScriptAcorn(ARC_SCRIPT);
|
||||
const unrolled = unrollComputedTimeline(ARC_SCRIPT);
|
||||
const after = parseGsapScriptAcorn(unrolled);
|
||||
|
||||
// Same animations, same times, same arcs/keyframes — the render is unchanged.
|
||||
expect(after.animations).toHaveLength(before.animations.length);
|
||||
expect(sig(after.animations)).toBe(sig(before.animations));
|
||||
});
|
||||
|
||||
it("produces only literal tweens (no helper, no provenance)", () => {
|
||||
const unrolled = unrollComputedTimeline(ARC_SCRIPT);
|
||||
expect(unrolled).not.toContain("addCycle");
|
||||
expect(unrolled).not.toContain("function ");
|
||||
const after = parseGsapScriptAcorn(unrolled);
|
||||
expect(after.animations.every((a) => a.provenance === undefined)).toBe(true);
|
||||
// Arc tweens survive as real motionPath arcs.
|
||||
expect(after.animations.filter((a) => a.arcPath?.enabled)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("unrolls a bounded for-loop", () => {
|
||||
const script = `const tl = gsap.timeline();
|
||||
for (let i = 0; i < 3; i++) { tl.to("#x", { x: 100, duration: 0.5 }, i * 0.5); }`;
|
||||
const unrolled = unrollComputedTimeline(script);
|
||||
expect(unrolled).not.toContain("for (");
|
||||
const after = parseGsapScriptAcorn(unrolled);
|
||||
expect(after.animations.map((a) => a.resolvedStart)).toEqual([0, 0.5, 1]);
|
||||
expect(after.animations.every((a) => a.provenance === undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves a fully-literal composition unchanged", () => {
|
||||
const script = `const tl = gsap.timeline();
|
||||
tl.from("#a", { opacity: 0, duration: 0.5 }, 0.1);`;
|
||||
expect(unrollComputedTimeline(script)).toBe(script);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Unroll computed GSAP timelines (helpers / bounded loops) into explicit literal
|
||||
* tweens — the source-rewrite behind the Studio "Unroll to edit" action.
|
||||
*
|
||||
* Strategy: the read parser already resolves each computed tween (positions,
|
||||
* motionPath arcs, keyframes, provenance). We serialize those resolved
|
||||
* animations back to literal `tl.*` statements and surgically replace the
|
||||
* top-level helper-call / loop statements that produced them (and drop the now
|
||||
* dead helper declarations) via magic-string, leaving the rest of the source —
|
||||
* literal tweens, comments, formatting — untouched. The result is a visual
|
||||
* no-op: re-parsing it yields the same animations, now all literal.
|
||||
*
|
||||
* Scope: top-level helper calls and loops (the common authoring shape). Tweens
|
||||
* whose origin can't be mapped to a top-level statement (e.g. helpers nested
|
||||
* inside other helpers) are left as-is rather than guessed at.
|
||||
*/
|
||||
import * as acorn from "acorn";
|
||||
import MagicString from "magic-string";
|
||||
import type { GsapAnimation } from "./gsapSerialize.js";
|
||||
import { serializeValue as valueToCode, safeJsKey as safeKey } from "./gsapSerialize.js";
|
||||
import { parseGsapScriptAcorn } from "./gsapParserAcorn.js";
|
||||
|
||||
// acorn nodes are structurally untyped here.
|
||||
type Node = any;
|
||||
|
||||
function propEntries(props: Record<string, number | string>): string[] {
|
||||
return Object.entries(props).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`);
|
||||
}
|
||||
|
||||
function motionPathEntry(anim: GsapAnimation): string {
|
||||
const waypoints = (anim.keyframes?.keyframes ?? [])
|
||||
.filter((k) => typeof k.properties.x === "number" && typeof k.properties.y === "number")
|
||||
.map((k) => `{ x: ${valueToCode(k.properties.x!)}, y: ${valueToCode(k.properties.y!)} }`);
|
||||
const curviness = anim.arcPath?.segments[0]?.curviness ?? 1;
|
||||
const autoRotate = anim.arcPath?.autoRotate;
|
||||
const extra = autoRotate ? `, autoRotate: ${valueToCode(autoRotate as number | string)}` : "";
|
||||
return `motionPath: { path: [${waypoints.join(", ")}], curviness: ${curviness}${extra} }`;
|
||||
}
|
||||
|
||||
function keyframesEntry(anim: GsapAnimation): string {
|
||||
const kfs = (anim.keyframes?.keyframes ?? []).map((k) => {
|
||||
const body = propEntries(k.properties);
|
||||
if (k.ease) body.push(`ease: ${valueToCode(k.ease)}`);
|
||||
return `"${k.percentage}%": { ${body.join(", ")} }`;
|
||||
});
|
||||
if (anim.keyframes?.easeEach) kfs.push(`easeEach: ${valueToCode(anim.keyframes.easeEach)}`);
|
||||
return `keyframes: { ${kfs.join(", ")} }`;
|
||||
}
|
||||
|
||||
/** The vars-object entries for a tween: motionPath/keyframes block, props, duration, ease, extras. */
|
||||
function buildVarsParts(anim: GsapAnimation): string[] {
|
||||
const parts: string[] = [];
|
||||
if (anim.arcPath?.enabled) parts.push(motionPathEntry(anim));
|
||||
else if (anim.keyframes) parts.push(keyframesEntry(anim));
|
||||
parts.push(...propEntries(anim.properties));
|
||||
if (anim.method !== "set" && anim.duration !== undefined) {
|
||||
parts.push(`duration: ${valueToCode(anim.duration)}`);
|
||||
}
|
||||
if (anim.ease) parts.push(`ease: ${valueToCode(anim.ease)}`);
|
||||
for (const [k, v] of Object.entries(anim.extras ?? {})) {
|
||||
parts.push(`${safeKey(k)}: ${valueToCode(v as number | string)}`);
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
/** Serialize one resolved animation to a literal `tl.*` statement (arc/keyframe-aware). */
|
||||
function serializeTweenStatement(timelineVar: string, anim: GsapAnimation): string {
|
||||
const obj = `{ ${buildVarsParts(anim).join(", ")} }`;
|
||||
const pos = valueToCode(
|
||||
anim.resolvedStart ?? (typeof anim.position === "number" ? anim.position : 0),
|
||||
);
|
||||
const sel = valueToCode(anim.targetSelector);
|
||||
if (anim.method === "fromTo") {
|
||||
const from = `{ ${propEntries(anim.fromProperties ?? {}).join(", ")} }`;
|
||||
return `${timelineVar}.fromTo(${sel}, ${from}, ${obj}, ${pos});`;
|
||||
}
|
||||
return `${timelineVar}.${anim.method}(${sel}, ${obj}, ${pos});`;
|
||||
}
|
||||
|
||||
/** A computed animation is one expanded from a helper or loop (not literal/dynamic). */
|
||||
function isComputed(anim: GsapAnimation): boolean {
|
||||
return anim.provenance?.kind === "helper" || anim.provenance?.kind === "loop";
|
||||
}
|
||||
|
||||
/** Top-level statements of the parsed program. */
|
||||
function topLevelStatements(script: string): Node[] {
|
||||
return acorn.parse(script, { ecmaVersion: "latest", sourceType: "script" }).body ?? [];
|
||||
}
|
||||
|
||||
/** The top-level statement whose source span contains [start, end], or null. */
|
||||
function enclosingTopLevel(statements: Node[], start: number, end: number): Node | null {
|
||||
for (const stmt of statements) {
|
||||
if (stmt.start <= start && stmt.end >= end) return stmt;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isHelperDeclNamed(stmt: Node, names: Set<string>): boolean {
|
||||
if (stmt.type === "FunctionDeclaration") return names.has(stmt.id?.name);
|
||||
if (stmt.type === "VariableDeclaration") {
|
||||
return (stmt.declarations ?? []).some((d: Node) => names.has(d.id?.name));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite `script` so top-level helper calls / loops that build the timeline
|
||||
* become explicit literal tweens. Returns the original script unchanged when
|
||||
* there is nothing statically-resolvable to unroll.
|
||||
*/
|
||||
export function unrollComputedTimeline(script: string): string {
|
||||
const parsed = parseGsapScriptAcorn(script);
|
||||
const computed = parsed.animations.filter((a) => isComputed(a) && a.provenance?.sourceRange);
|
||||
if (computed.length === 0) return script;
|
||||
|
||||
const statements = topLevelStatements(script);
|
||||
|
||||
// Group computed animations by the top-level statement that produced them,
|
||||
// preserving source order within each group.
|
||||
const byStatement = new Map<Node, GsapAnimation[]>();
|
||||
const helperNames = new Set<string>();
|
||||
for (const anim of computed) {
|
||||
if (anim.provenance?.fn) helperNames.add(anim.provenance.fn);
|
||||
const [s, e] = anim.provenance!.sourceRange!;
|
||||
const stmt = enclosingTopLevel(statements, s, e);
|
||||
if (!stmt) continue; // nested origin — leave it; can't map to a top-level edit
|
||||
const list = byStatement.get(stmt) ?? [];
|
||||
list.push(anim);
|
||||
byStatement.set(stmt, list);
|
||||
}
|
||||
if (byStatement.size === 0) return script;
|
||||
|
||||
const ms = new MagicString(script);
|
||||
for (const [stmt, anims] of byStatement) {
|
||||
const literals = anims.map((a) => serializeTweenStatement(parsed.timelineVar, a)).join("\n");
|
||||
ms.overwrite(stmt.start, stmt.end, literals);
|
||||
}
|
||||
// Drop the now-dead helper declarations.
|
||||
for (const stmt of statements) {
|
||||
if (isHelperDeclNamed(stmt, helperNames)) ms.remove(stmt.start, stmt.end);
|
||||
}
|
||||
return ms.toString();
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
// fallow-ignore-file code-duplication
|
||||
/**
|
||||
* T6c — acorn write path with magic-string offset-splice.
|
||||
*
|
||||
* Verifies that each write op touches only the intended byte span and leaves
|
||||
* every other character identical to the original source.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
addAnimationToScript,
|
||||
addKeyframeToScript,
|
||||
convertToKeyframesFromScript,
|
||||
removeAnimationFromScript,
|
||||
removeKeyframeFromScript,
|
||||
updateAnimationInScript,
|
||||
updateKeyframeInScript,
|
||||
} from "./gsapWriterAcorn.js";
|
||||
import { parseGsapScript } from "./gsapParser.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixture scripts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SCRIPT_A = `\
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.to("#hero", { opacity: 1, duration: 0.5, ease: "power3.out" }, 0.2);
|
||||
window.__timelines["t"] = tl;`;
|
||||
|
||||
const SCRIPT_B = `\
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.to("#hero", { opacity: 1, duration: 0.5, ease: "power3.out" }, 0);
|
||||
tl.to("#hero", { opacity: 0, duration: 0.3, ease: "power3.in" }, 1);
|
||||
window.__timelines["t"] = tl;`;
|
||||
|
||||
const SCRIPT_C = `\
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.from(".a", { opacity: 0, duration: 0.5 }, 0)
|
||||
.from(".b", { opacity: 0, duration: 0.3 }, 0.5);
|
||||
window.__timelines["t"] = tl;`;
|
||||
|
||||
// 3-keyframe script so removal leaves ≥2 kfs (no collapse needed)
|
||||
const SCRIPT_D = `\
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.to("#box", { keyframes: { "0%": { opacity: 0 }, "50%": { opacity: 0.7 }, "100%": { opacity: 1 } }, duration: 0.5 }, 0.2);
|
||||
window.__timelines["t"] = tl;`;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// No-op identity
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("T6c — no-op identity", () => {
|
||||
it("updateAnimationInScript with empty updates returns identical script", () => {
|
||||
const result = updateAnimationInScript(SCRIPT_A, "#hero-to-200-visual", {});
|
||||
expect(result).toBe(SCRIPT_A);
|
||||
});
|
||||
|
||||
it("updateAnimationInScript with unknown ID returns identical script", () => {
|
||||
const result = updateAnimationInScript(SCRIPT_A, "not-a-real-id", { ease: "power2.in" });
|
||||
expect(result).toBe(SCRIPT_A);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// updateAnimationInScript
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("T6c — updateAnimationInScript", () => {
|
||||
it("updates ease value in-place", () => {
|
||||
const result = updateAnimationInScript(SCRIPT_A, "#hero-to-200-visual", {
|
||||
ease: "power2.in",
|
||||
});
|
||||
expect(result).toContain('"power2.in"');
|
||||
expect(result).not.toContain('"power3.out"');
|
||||
// Preamble + postamble unchanged
|
||||
expect(result).toContain("var tl = gsap.timeline({ paused: true });");
|
||||
expect(result).toContain('window.__timelines["t"] = tl;');
|
||||
});
|
||||
|
||||
it("updates duration value in-place", () => {
|
||||
const result = updateAnimationInScript(SCRIPT_A, "#hero-to-200-visual", {
|
||||
duration: 1.2,
|
||||
});
|
||||
expect(result).toContain("duration: 1.2");
|
||||
expect(result).not.toContain("duration: 0.5");
|
||||
expect(result).toContain('"power3.out"');
|
||||
});
|
||||
|
||||
it("updates position arg in-place", () => {
|
||||
const result = updateAnimationInScript(SCRIPT_A, "#hero-to-200-visual", {
|
||||
position: 0.5,
|
||||
});
|
||||
expect(result).toContain("}, 0.5)");
|
||||
expect(result).not.toContain("}, 0.2)");
|
||||
expect(result).toContain("opacity: 1");
|
||||
});
|
||||
|
||||
it("inserts ease when property was absent", () => {
|
||||
const noEase = `\
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.to("#hero", { opacity: 1, duration: 0.5 }, 0.2);
|
||||
window.__timelines["t"] = tl;`;
|
||||
const result = updateAnimationInScript(noEase, "#hero-to-200-visual", {
|
||||
ease: "power3.out",
|
||||
});
|
||||
expect(result).toContain('ease: "power3.out"');
|
||||
// Duration, opacity, position unchanged
|
||||
expect(result).toContain("duration: 0.5");
|
||||
expect(result).toContain("opacity: 1");
|
||||
expect(result).toContain("}, 0.2)");
|
||||
});
|
||||
|
||||
it("updates fromTo — ease on toVars", () => {
|
||||
const fromTo = `\
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.fromTo("#hero", { opacity: 0 }, { opacity: 1, duration: 0.5, ease: "power3.out" }, 0.1);
|
||||
window.__timelines["t"] = tl;`;
|
||||
// ID: target="#hero", method="fromTo", pos=0.1 → posKey=100, propertyGroup=visual
|
||||
const result = updateAnimationInScript(fromTo, "#hero-fromTo-100-visual", {
|
||||
ease: "back.out",
|
||||
});
|
||||
expect(result).toContain('"back.out"');
|
||||
expect(result).not.toContain('"power3.out"');
|
||||
expect(result).toContain("opacity: 0");
|
||||
});
|
||||
|
||||
it("byte-identity outside edited ease span", () => {
|
||||
const result = updateAnimationInScript(SCRIPT_A, "#hero-to-200-visual", {
|
||||
ease: "power2.in",
|
||||
});
|
||||
const oldEaseStart = SCRIPT_A.indexOf('"power3.out"');
|
||||
const newEaseStart = result.indexOf('"power2.in"');
|
||||
// Everything before the ease value is identical
|
||||
expect(result.slice(0, newEaseStart)).toBe(SCRIPT_A.slice(0, oldEaseStart));
|
||||
// Everything after the ease value close-quote is identical
|
||||
const oldAfter = SCRIPT_A.slice(oldEaseStart + '"power3.out"'.length);
|
||||
const newAfter = result.slice(newEaseStart + '"power2.in"'.length);
|
||||
expect(newAfter).toBe(oldAfter);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// removeAnimationFromScript
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("T6c — removeAnimationFromScript", () => {
|
||||
it("removes a standalone tween statement", () => {
|
||||
const result = removeAnimationFromScript(SCRIPT_B, "#hero-to-0-visual");
|
||||
expect(result).not.toContain("power3.out");
|
||||
expect(result).toContain("power3.in");
|
||||
expect(result).toContain('window.__timelines["t"] = tl;');
|
||||
});
|
||||
|
||||
it("removes last chain link (outer call)", () => {
|
||||
// SCRIPT_C: tl.from(".a",...,0).from(".b",...,0.5)
|
||||
// Remove .b (outermost call = last in source)
|
||||
const result = removeAnimationFromScript(SCRIPT_C, ".b-from-500-visual");
|
||||
expect(result).toContain('.from(".a"');
|
||||
expect(result).not.toContain('.from(".b"');
|
||||
// The statement should still end with ; (no dangling chain)
|
||||
expect(result).toContain("}, 0);");
|
||||
});
|
||||
|
||||
it("removes inner chain link", () => {
|
||||
// SCRIPT_C: tl.from(".a",...,0).from(".b",...,0.5)
|
||||
// Remove .a (innermost call = first in source)
|
||||
const result = removeAnimationFromScript(SCRIPT_C, ".a-from-0-visual");
|
||||
expect(result).not.toContain('.from(".a"');
|
||||
expect(result).toContain('.from(".b"');
|
||||
// Chain is still rooted at tl (whitespace between tl and .from is valid JS)
|
||||
expect(result).toMatch(/tl[\s.]*from\("\.b"/);
|
||||
});
|
||||
|
||||
it("unknown ID returns script unchanged", () => {
|
||||
const result = removeAnimationFromScript(SCRIPT_A, "nonexistent-id");
|
||||
expect(result).toBe(SCRIPT_A);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// addAnimationToScript
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("T6c — addAnimationToScript", () => {
|
||||
it("inserts new tween after last existing tween", () => {
|
||||
const { script: result } = addAnimationToScript(SCRIPT_A, {
|
||||
targetSelector: "#new",
|
||||
method: "to",
|
||||
position: 0.5,
|
||||
duration: 0.3,
|
||||
properties: { x: 100 },
|
||||
});
|
||||
expect(result).toContain('tl.to("#new"');
|
||||
expect(result).toContain("x: 100");
|
||||
expect(result).toContain("duration: 0.3");
|
||||
// Original content preserved
|
||||
expect(result).toContain('tl.to("#hero"');
|
||||
expect(result).toContain('window.__timelines["t"] = tl;');
|
||||
// New tween comes after hero tween
|
||||
expect(result.indexOf('tl.to("#new"')).toBeGreaterThan(result.indexOf('tl.to("#hero"'));
|
||||
});
|
||||
|
||||
it("returns a non-empty stable id for the new animation", () => {
|
||||
const { id } = addAnimationToScript(SCRIPT_A, {
|
||||
targetSelector: "#new",
|
||||
method: "to",
|
||||
position: 0.5,
|
||||
duration: 0.3,
|
||||
properties: { x: 100 },
|
||||
});
|
||||
expect(id).toBeTruthy();
|
||||
expect(typeof id).toBe("string");
|
||||
});
|
||||
|
||||
it("inserts after timeline declaration when script has no tweens", () => {
|
||||
const empty = `var tl = gsap.timeline({ paused: true });\nwindow.__timelines["t"] = tl;`;
|
||||
const { script: result } = addAnimationToScript(empty, {
|
||||
targetSelector: "#hero",
|
||||
method: "to",
|
||||
position: 0,
|
||||
duration: 0.5,
|
||||
properties: { opacity: 1 },
|
||||
});
|
||||
expect(result).toContain('tl.to("#hero"');
|
||||
// Inserted after timeline declaration
|
||||
expect(result.indexOf('tl.to("#hero"')).toBeGreaterThan(result.indexOf("gsap.timeline"));
|
||||
});
|
||||
|
||||
it("inserts a global gsap.set BEFORE the timeline declaration", () => {
|
||||
// A base set emitted after the tween calls is wiped by GSAP's from()-init
|
||||
// revert on the first backwards render (studio soft-reload rebind) — it
|
||||
// must precede the timeline so the from() records it as pre-tween state.
|
||||
const script = `\
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.from("#hero", { scale: 0.9, duration: 0.5 }, 0.2);
|
||||
window.__timelines["t"] = tl;`;
|
||||
const { script: result, id } = addAnimationToScript(script, {
|
||||
targetSelector: "#hero",
|
||||
method: "set",
|
||||
position: 0,
|
||||
properties: { x: 267, y: 20 },
|
||||
global: true,
|
||||
});
|
||||
expect(result).toContain('gsap.set("#hero", { x: 267, y: 20 });');
|
||||
expect(result.indexOf('gsap.set("#hero"')).toBeLessThan(result.indexOf("gsap.timeline"));
|
||||
expect(id).toBeTruthy();
|
||||
// Round-trip: the id resolves back to the inserted set
|
||||
const updated = updateAnimationInScript(result, id, { properties: { x: 300, y: 20 } });
|
||||
expect(updated).toContain("x: 300");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Legacy global-set relocation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("T6c — updateAnimationInScript relocates legacy trailing global set", () => {
|
||||
const LEGACY = `\
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.from("#hero", { scale: 0.9, duration: 0.5 }, 0.2);
|
||||
gsap.set("#hero", { x: 267, y: 20 });
|
||||
window.__timelines["t"] = tl;`;
|
||||
|
||||
it("moves a post-timeline global set above the declaration when updated", () => {
|
||||
const result = updateAnimationInScript(LEGACY, "#hero-set-0-position", {
|
||||
properties: { x: 300, y: 20 },
|
||||
});
|
||||
expect(result).toContain("x: 300");
|
||||
expect(result.indexOf("gsap.set(")).toBeLessThan(result.indexOf("gsap.timeline"));
|
||||
expect(result).toContain('window.__timelines["t"] = tl;');
|
||||
});
|
||||
|
||||
it("leaves an already-hoisted global set in place", () => {
|
||||
const hoisted = `\
|
||||
gsap.set("#hero", { x: 267, y: 20 });
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.from("#hero", { scale: 0.9, duration: 0.5 }, 0.2);
|
||||
window.__timelines["t"] = tl;`;
|
||||
const result = updateAnimationInScript(hoisted, "#hero-set-0-position", {
|
||||
properties: { x: 300, y: 20 },
|
||||
});
|
||||
expect(result).toContain("x: 300");
|
||||
expect(result.indexOf("gsap.set(")).toBeLessThan(result.indexOf("gsap.timeline"));
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Keyframe write ops
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("T6c — keyframe write ops", () => {
|
||||
it("updateKeyframeInScript replaces keyframe value at given percentage", () => {
|
||||
// Update 50% from { opacity: 0.7 } to { opacity: 0.5 }
|
||||
const result = updateKeyframeInScript(SCRIPT_D, "#box-to-200-visual", 50, { opacity: 0.5 });
|
||||
expect(result).toContain("opacity: 0.5");
|
||||
expect(result).not.toContain("opacity: 0.7");
|
||||
// Other keyframes unchanged
|
||||
expect(result).toContain('"0%": { opacity: 0 }');
|
||||
expect(result).toContain('"100%": { opacity: 1 }');
|
||||
});
|
||||
|
||||
it("updateKeyframeInScript preserves bytes outside the edited value", () => {
|
||||
const result = updateKeyframeInScript(SCRIPT_D, "#box-to-200-visual", 100, {
|
||||
opacity: 0.9,
|
||||
});
|
||||
// The 50% keyframe is untouched
|
||||
expect(result).toContain('"50%": { opacity: 0.7 }');
|
||||
// Duration and position are unchanged
|
||||
expect(result).toContain("duration: 0.5");
|
||||
expect(result).toContain("}, 0.2)");
|
||||
});
|
||||
|
||||
it("updateKeyframeInScript edits ARRAY-form keyframes by percentage→index (the #shuttle case)", () => {
|
||||
// Array-form keyframes carry no explicit percentages; GSAP distributes 4 of
|
||||
// them evenly → 0 / 33.3 / 66.7 / 100. Dragging the 2nd motion-path node
|
||||
// (pct 33.3) must rewrite array index 1 — not no-op (regression: array form
|
||||
// bailed the ObjectExpression check, so the drag committed nothing).
|
||||
const script =
|
||||
"const tl = gsap.timeline();\n" +
|
||||
'tl.to("#shuttle", { keyframes: [{ x: 0, y: 0 }, { x: 520, y: 120 }, { x: 1040, y: 0 }, { x: 1480, y: 160 }], duration: 4.4, ease: "none" }, 5.2);';
|
||||
const result = updateKeyframeInScript(script, "#shuttle-to-5200-position", 33.3, {
|
||||
x: 503,
|
||||
y: 642,
|
||||
});
|
||||
expect(result).not.toBe(script); // actually changed (not a no-op)
|
||||
expect(result).toContain("x: 503");
|
||||
expect(result).toContain("y: 642");
|
||||
expect(result).not.toContain("x: 520"); // index 1 replaced
|
||||
// Sibling array entries untouched.
|
||||
expect(result).toContain("{ x: 0, y: 0 }");
|
||||
expect(result).toContain("{ x: 1040, y: 0 }");
|
||||
expect(result).toContain("{ x: 1480, y: 160 }");
|
||||
});
|
||||
|
||||
it("updateAnimationInScript apply-to-all sets easeEach and strips per-keyframe eases", () => {
|
||||
const script =
|
||||
"const tl = gsap.timeline();\n" +
|
||||
'tl.to("#box", { keyframes: { "0%": { x: 0 }, "50%": { x: 50, ease: "power2.in" }, "100%": { x: 100, ease: "back.out" }, easeEach: "none" }, duration: 1 }, 0);';
|
||||
const id = parseGsapScript(script).animations[0]!.id;
|
||||
const result = updateAnimationInScript(script, id, {
|
||||
easeEach: "power2.out",
|
||||
resetKeyframeEases: true,
|
||||
});
|
||||
// easeEach updated to the chosen ease …
|
||||
expect(result).toContain('easeEach: "power2.out"');
|
||||
// … and every per-keyframe override is gone, so all segments use easeEach.
|
||||
expect(result).not.toContain('ease: "power2.in"');
|
||||
expect(result).not.toContain('ease: "back.out"');
|
||||
// keyframe property values are preserved.
|
||||
const kf = parseGsapScript(result).animations[0]!.keyframes!;
|
||||
expect(kf.easeEach).toBe("power2.out");
|
||||
expect(kf.keyframes.every((k) => k.ease === undefined)).toBe(true);
|
||||
expect(kf.keyframes.map((k) => k.properties.x)).toEqual([0, 50, 100]);
|
||||
});
|
||||
|
||||
it("addKeyframeToScript — ARRAY-form normalizes to object form + inserts 50%", () => {
|
||||
const script =
|
||||
"const tl = gsap.timeline();\n" +
|
||||
'tl.to("#shuttle", { keyframes: [{ x: 0, y: 0 }, { x: 520, y: 120 }, { x: 1040, y: 0 }, { x: 1480, y: 160 }], duration: 4.4, ease: "none" }, 5.2);';
|
||||
const result = addKeyframeToScript(script, "#shuttle-to-5200-position", 50, { x: 780, y: 60 });
|
||||
expect(result).not.toBe(script); // not a no-op
|
||||
expect(result).toContain('"50%"'); // converted to percentage-object form
|
||||
expect(result).toContain("x: 780");
|
||||
// Original even-distribution stops preserved as percentage keys.
|
||||
expect(result).toContain('"0%"');
|
||||
expect(result).toContain('"100%"');
|
||||
});
|
||||
|
||||
it("addKeyframeToScript inserts new percentage in sorted order", () => {
|
||||
const result = addKeyframeToScript(SCRIPT_D, "#box-to-200-visual", 25, { opacity: 0.3 });
|
||||
expect(result).toContain('"25%"');
|
||||
expect(result).toContain("opacity: 0.3");
|
||||
// Original keyframes preserved
|
||||
expect(result).toContain('"0%": { opacity: 0 }');
|
||||
expect(result).toContain('"50%": { opacity: 0.7 }');
|
||||
// 25% appears before 50% in the string
|
||||
expect(result.indexOf('"25%"')).toBeLessThan(result.indexOf('"50%"'));
|
||||
});
|
||||
|
||||
it("addKeyframeToScript replaces value when percentage already exists", () => {
|
||||
const result = addKeyframeToScript(SCRIPT_D, "#box-to-200-visual", 50, { opacity: 0.99 });
|
||||
expect(result).toContain("opacity: 0.99");
|
||||
expect(result).not.toContain("opacity: 0.7");
|
||||
// Only one "50%" in the result
|
||||
expect((result.match(/"50%"/g) ?? []).length).toBe(1);
|
||||
});
|
||||
|
||||
it("addKeyframeToScript merges a new property into an existing keyframe, preserving siblings", () => {
|
||||
// 50% already holds { opacity: 0.7 }; adding x must NOT drop opacity.
|
||||
const result = addKeyframeToScript(SCRIPT_D, "#box-to-200-visual", 50, { x: 100 });
|
||||
expect(result).toContain("opacity: 0.7");
|
||||
expect(result).toContain("x: 100");
|
||||
expect((result.match(/"50%"/g) ?? []).length).toBe(1);
|
||||
});
|
||||
|
||||
it("removeKeyframeFromScript removes the target percentage", () => {
|
||||
// Remove 50% from 0%/50%/100% → leaves 0%/100% (no collapse in T6c)
|
||||
const result = removeKeyframeFromScript(SCRIPT_D, "#box-to-200-visual", 50);
|
||||
expect(result).not.toContain('"50%"');
|
||||
expect(result).toContain('"0%"');
|
||||
expect(result).toContain('"100%"');
|
||||
});
|
||||
|
||||
it("updateKeyframeInScript on unknown id returns script unchanged", () => {
|
||||
const result = updateKeyframeInScript(SCRIPT_D, "bad-id", 50, { opacity: 0.5 });
|
||||
expect(result).toBe(SCRIPT_D);
|
||||
});
|
||||
});
|
||||
|
||||
describe("T6c — convertToKeyframesFromScript: global gsap.set", () => {
|
||||
const SCRIPT_GLOBAL_SET = `\
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
gsap.set("#card", { rotationX: 50, rotationY: 20 });
|
||||
window.__timelines["t"] = tl;`;
|
||||
|
||||
it("re-roots a global gsap.set onto the timeline (tl.to + position), not gsap.to", () => {
|
||||
const animId = parseGsapScript(SCRIPT_GLOBAL_SET).animations[0].id;
|
||||
const result = convertToKeyframesFromScript(SCRIPT_GLOBAL_SET, animId, undefined, 4);
|
||||
// Off-timeline gsap.to would fire once at load and be unseekable; must be tl.to.
|
||||
expect(result).toMatch(/tl\.to\(\s*"#card"/);
|
||||
expect(result).not.toMatch(/gsap\.to\(/);
|
||||
expect(result).toContain("keyframes:");
|
||||
const reparsed = parseGsapScript(result).animations[0];
|
||||
expect(reparsed.keyframes).toBeTruthy();
|
||||
expect(reparsed.global).toBeFalsy();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,317 @@
|
||||
// fallow-ignore-file code-duplication
|
||||
/**
|
||||
* Correctness regressions for the SDK-cutover review (PR #1539).
|
||||
*
|
||||
* Each test asserts the REAL-WORLD-CORRECT result of a write op — NOT mere
|
||||
* agreement between the two writers. Several of these scenarios were cases where
|
||||
* both writers were identically wrong (so the recast-vs-acorn parity suite stayed
|
||||
* green); these tests pin the corrected behavior.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
removeKeyframeFromScript,
|
||||
updateKeyframeInScript,
|
||||
setArcPathInScript,
|
||||
updateArcSegmentInScript,
|
||||
splitAnimationsInScript,
|
||||
unrollDynamicAnimations,
|
||||
updateAnimationInScript,
|
||||
convertToKeyframesFromScript,
|
||||
} from "./gsapWriterAcorn.js";
|
||||
import { parseGsapScriptAcornForWrite } from "./gsapParserAcorn.js";
|
||||
|
||||
// ── #2 — findKfPropByPct must hit the CLOSEST keyframe, not first-within-2% ──
|
||||
|
||||
const KF_DENSE = `var tl = gsap.timeline({ paused: true });
|
||||
tl.to("#box", { keyframes: { "0%": { opacity: 0 }, "49%": { opacity: 0.4 }, "50%": { opacity: 0.5 }, "100%": { opacity: 1 } }, duration: 1 }, 0);`;
|
||||
const KF_DENSE_ID = "#box-to-0-visual";
|
||||
|
||||
describe("#2 — keyframe ops target the closest percentage, not first-within-tolerance", () => {
|
||||
it("removing 50% removes 50% and KEEPS the neighboring 49%", () => {
|
||||
const result = removeKeyframeFromScript(KF_DENSE, KF_DENSE_ID, 50);
|
||||
expect(result).not.toContain('"50%"');
|
||||
expect(result).toContain('"49%"');
|
||||
expect(result).toContain("opacity: 0.4"); // 49% body intact
|
||||
expect(result).toContain('"0%"');
|
||||
expect(result).toContain('"100%"');
|
||||
});
|
||||
|
||||
it("updating ~50% overwrites 50% (closest), not 49%", () => {
|
||||
const result = updateKeyframeInScript(KF_DENSE, KF_DENSE_ID, 51, { opacity: 0.99 });
|
||||
// 50% is closest to 51 (dist 1) vs 49 (dist 2) — 50% gets the new value.
|
||||
expect(result).toContain('"50%": { opacity: 0.99 }');
|
||||
// 49% must be untouched.
|
||||
expect(result).toContain('"49%": { opacity: 0.4 }');
|
||||
});
|
||||
});
|
||||
|
||||
// ── #4 — enableArcPath on an x/y-only tween must not produce '{}' ──
|
||||
|
||||
describe("#4 — enableArcPath on x/y-only vars yields a real motionPath", () => {
|
||||
const XY_ONLY = `var tl = gsap.timeline({ paused: true });
|
||||
tl.to("#h", { x: 100, y: 50 }, 0);`;
|
||||
|
||||
it("emits a motionPath, drops x/y, and reparses with the arc enabled", () => {
|
||||
const out = setArcPathInScript(XY_ONLY, "#h-to-0-position", {
|
||||
enabled: true,
|
||||
autoRotate: false,
|
||||
segments: [],
|
||||
});
|
||||
expect(out).toContain("motionPath");
|
||||
expect(out).toContain("path:");
|
||||
// The collision bug produced a bare '{}' (no motionPath, no x/y).
|
||||
expect(out).not.toMatch(/\{\s*\}/);
|
||||
|
||||
// x/y are folded into the motionPath waypoints, not left as top-level vars.
|
||||
const reparsed = parseGsapScriptAcornForWrite(out);
|
||||
const anim = reparsed?.located[0]?.animation;
|
||||
expect(anim?.arcPath?.enabled).toBe(true);
|
||||
expect("x" in (anim?.properties ?? {})).toBe(false);
|
||||
expect("y" in (anim?.properties ?? {})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── #5 — split midpoint uses forward baseline (earlier tweens), not reverse ──
|
||||
|
||||
describe("#5 — split-spanning midpoint interpolates from the forward baseline", () => {
|
||||
// A ends at x:100 (t=0..1). B runs x:?→300 over t=1..3. Split at t=2 lands at
|
||||
// B's 50% point: mid = 100 + (300-100)*0.5 = 200 (NOT 150 from a 0 baseline).
|
||||
const TWO_TWEENS = `var tl = gsap.timeline({ paused: true });
|
||||
tl.to("#el", { x: 100, duration: 1 }, 0);
|
||||
tl.to("#el", { x: 300, duration: 2 }, 1);`;
|
||||
|
||||
it("computes midpoint x = 200 (100 + (300-100)*0.5), not 150", () => {
|
||||
const { script } = splitAnimationsInScript(TWO_TWEENS, {
|
||||
originalId: "el",
|
||||
newId: "el2",
|
||||
splitTime: 2,
|
||||
});
|
||||
expect(script).toContain("x: 200");
|
||||
expect(script).not.toContain("x: 150");
|
||||
// The new element's second half starts from the midpoint, not from 0.
|
||||
expect(script).toContain('tl.fromTo("#el2", { x: 200 }');
|
||||
});
|
||||
});
|
||||
|
||||
// ── #9 — unroll preserves non-target statements (tl.set) per iteration ──
|
||||
|
||||
describe("#9 — unrollDynamicAnimations keeps sibling statements in the loop body", () => {
|
||||
const LOOP = `var tl = gsap.timeline({ paused: true });
|
||||
for (let i = 0; i < 2; i++) {
|
||||
tl.set(items[i], { autoAlpha: 0 }, 0);
|
||||
tl.to(items[i], { opacity: 1, duration: 1 }, 0);
|
||||
}`;
|
||||
|
||||
it("the tl.set initial-state lines survive after unrolling the tl.to", () => {
|
||||
const parsed = parseGsapScriptAcornForWrite(LOOP);
|
||||
const targetId = parsed?.located.find((l) => l.animation.method === "to")?.id ?? "";
|
||||
const out = unrollDynamicAnimations(LOOP, targetId, [
|
||||
{
|
||||
selector: "#a",
|
||||
keyframes: [
|
||||
{ percentage: 0, properties: { opacity: 0 } },
|
||||
{ percentage: 100, properties: { opacity: 1 } },
|
||||
],
|
||||
},
|
||||
{
|
||||
selector: "#b",
|
||||
keyframes: [
|
||||
{ percentage: 0, properties: { opacity: 0 } },
|
||||
{ percentage: 100, properties: { opacity: 1 } },
|
||||
],
|
||||
},
|
||||
]);
|
||||
// Both tl.set lines must remain (one per iteration) — the blanket-overwrite
|
||||
// bug destroyed every non-target statement in the loop body.
|
||||
expect((out.match(/tl\.set\(/g) ?? []).length).toBe(2);
|
||||
expect(out).toContain("autoAlpha: 0");
|
||||
// The for-loop itself is gone (unrolled).
|
||||
expect(out).not.toContain("for (");
|
||||
// The target tween is unrolled to static selectors.
|
||||
expect(out).toContain('tl.to("#a"');
|
||||
expect(out).toContain('tl.to("#b"');
|
||||
});
|
||||
|
||||
it("an empty element list is a no-op, not an animation-deleting overwrite", () => {
|
||||
const parsed = parseGsapScriptAcornForWrite(LOOP);
|
||||
const targetId = parsed?.located.find((l) => l.animation.method === "to")?.id ?? "";
|
||||
// Empty elements has no unrolled form — overwriting the loop with zero calls
|
||||
// would silently delete the animation. Writer must return the script verbatim.
|
||||
expect(unrollDynamicAnimations(LOOP, targetId, [])).toBe(LOOP);
|
||||
});
|
||||
});
|
||||
|
||||
// ── R3 — unsafe sibling reproduction must refuse (no-op), never corrupt/drop ──
|
||||
|
||||
const TWO_EL = [
|
||||
{ selector: "#a", keyframes: [{ percentage: 100, properties: { opacity: 1 } }] },
|
||||
{ selector: "#b", keyframes: [{ percentage: 100, properties: { opacity: 1 } }] },
|
||||
];
|
||||
function targetToId(script: string): string {
|
||||
return (
|
||||
parseGsapScriptAcornForWrite(script)?.located.find((l) => l.animation.method === "to")?.id ?? ""
|
||||
);
|
||||
}
|
||||
function parses(src: string): boolean {
|
||||
try {
|
||||
new Function(src);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
describe("R3 — unroll refuses (no-ops) when siblings can't be safely reproduced", () => {
|
||||
// R2 carried a forEach WITH a sibling tl.set to the blanket overwrite, which
|
||||
// dropped the tl.set (elements start visible instead of hidden). The numeric
|
||||
// index a `for` loop provides isn't available, so we now refuse instead.
|
||||
it("forEach with a sibling statement is left untouched, not flattened-and-dropped", () => {
|
||||
const FOREACH = `var tl = gsap.timeline({ paused: true });
|
||||
items.forEach((item, i) => {
|
||||
tl.set(item, { autoAlpha: 0 }, 0);
|
||||
tl.to(item, { opacity: 1, duration: 1 }, 0);
|
||||
});`;
|
||||
expect(unrollDynamicAnimations(FOREACH, targetToId(FOREACH), TWO_EL)).toBe(FOREACH);
|
||||
});
|
||||
|
||||
// R3 #1 — object shorthand { i }: substituting the value yields `{ 0 }` (invalid).
|
||||
it("object shorthand using the index refuses rather than emit invalid `{ 0 }`", () => {
|
||||
const SHORTHAND = `var tl = gsap.timeline({ paused: true });
|
||||
for (let i = 0; i < 2; i++) {
|
||||
tl.set(items[i], { data: { i } }, 0);
|
||||
tl.to(items[i], { opacity: 1, duration: 1 }, 0);
|
||||
}`;
|
||||
const out = unrollDynamicAnimations(SHORTHAND, targetToId(SHORTHAND), TWO_EL);
|
||||
expect(out).toBe(SHORTHAND);
|
||||
expect(parses(out)).toBe(true);
|
||||
});
|
||||
|
||||
// R3 #2 — a sibling that re-declares the index (nested for / shadowing).
|
||||
it("a sibling shadowing the index refuses rather than rewrite the inner binding", () => {
|
||||
const SHADOW = `var tl = gsap.timeline({ paused: true });
|
||||
for (let i = 0; i < 2; i++) {
|
||||
tl.set(items[i], { onStart() { for (let i = 0; i < 3; i++) log(i); } }, 0);
|
||||
tl.to(items[i], { opacity: 1, duration: 1 }, 0);
|
||||
}`;
|
||||
const out = unrollDynamicAnimations(SHADOW, targetToId(SHADOW), TWO_EL);
|
||||
expect(out).toBe(SHADOW);
|
||||
expect(parses(out)).toBe(true);
|
||||
});
|
||||
|
||||
// The safe for-loop sibling case must still unroll (regression guard).
|
||||
it("a plain for-loop with an items[i] sibling still unrolls and preserves it", () => {
|
||||
const SAFE = `var tl = gsap.timeline({ paused: true });
|
||||
for (let i = 0; i < 2; i++) {
|
||||
tl.set(items[i], { autoAlpha: 0 }, 0);
|
||||
tl.to(items[i], { opacity: 1, duration: 1 }, 0);
|
||||
}`;
|
||||
const out = unrollDynamicAnimations(SAFE, targetToId(SAFE), TWO_EL);
|
||||
expect((out.match(/tl\.set\(/g) ?? []).length).toBe(2);
|
||||
expect(out).not.toContain("for (");
|
||||
expect(parses(out)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── R2 #5 — index substitution is AST-based: string literals are never corrupted ──
|
||||
|
||||
describe("R2 — unroll substitutes real index uses but not the index char in strings", () => {
|
||||
const LOOP_STR = `var tl = gsap.timeline({ paused: true });
|
||||
for (let i = 0; i < 2; i++) {
|
||||
tl.set(items[i], { id: "row-i" }, 0);
|
||||
tl.to(items[i], { opacity: 1, duration: 1 }, 0);
|
||||
}`;
|
||||
|
||||
it('rewrites items[i] per iteration but leaves the "row-i" string intact', () => {
|
||||
const parsed = parseGsapScriptAcornForWrite(LOOP_STR);
|
||||
const targetId = parsed?.located.find((l) => l.animation.method === "to")?.id ?? "";
|
||||
const out = unrollDynamicAnimations(LOOP_STR, targetId, [
|
||||
{ selector: "#a", keyframes: [{ percentage: 100, properties: { opacity: 1 } }] },
|
||||
{ selector: "#b", keyframes: [{ percentage: 100, properties: { opacity: 1 } }] },
|
||||
]);
|
||||
// Real uses of the index are substituted…
|
||||
expect(out).toContain("items[0]");
|
||||
expect(out).toContain("items[1]");
|
||||
// …but the literal "row-i" is untouched (the regex bug rewrote it to "row-0").
|
||||
expect(out).toContain('"row-i"');
|
||||
expect(out).not.toContain('"row-0"');
|
||||
});
|
||||
});
|
||||
|
||||
// ── #10 — per-segment curviness survives serialization ──
|
||||
|
||||
describe("#10 — updateArcSegment on a non-first segment reflects its curviness", () => {
|
||||
// 3-waypoint arc, uniform curviness 1.5 → change segment 1 to curviness 3.
|
||||
const ARC = `var tl = gsap.timeline({ paused: true });
|
||||
tl.to("#h", { motionPath: { path: [{x: 0, y: 0}, {x: 100, y: 50}, {x: 200, y: 0}], curviness: 1.5 }, duration: 1 }, 0);`;
|
||||
|
||||
it("does not drop the second segment's curve (no longer serializes only segments[0])", () => {
|
||||
const parsed = parseGsapScriptAcornForWrite(ARC);
|
||||
const id = parsed?.located[0]?.id ?? "";
|
||||
const out = updateArcSegmentInScript(ARC, id, 1, { curviness: 3 });
|
||||
|
||||
// With differing per-segment curviness, the only representation that carries
|
||||
// both is the cubic form. The simple form (which only emits one scalar
|
||||
// curviness) would silently drop segment 1's change.
|
||||
expect(out).toContain('type: "cubic"');
|
||||
|
||||
// Compare against the SAME-shape arc left at uniform curviness 1.5: the
|
||||
// segment-1 control points must DIFFER, proving curviness 3 took effect.
|
||||
const uniformOut = updateArcSegmentInScript(ARC, id, 1, { curviness: 1.5 });
|
||||
expect(out).not.toBe(uniformOut);
|
||||
});
|
||||
});
|
||||
|
||||
// ── #11 — disableArcPath recovers NEGATIVE destination coordinates ──
|
||||
|
||||
describe("#11 — disableArcPath restores negative waypoint coords", () => {
|
||||
it("restores x:-120, y:-40 on the flattened tween", () => {
|
||||
const XY_NEG = `var tl = gsap.timeline({ paused: true });
|
||||
tl.to("#h", { x: -120, y: -40, duration: 1 }, 0);`;
|
||||
const enabled = setArcPathInScript(XY_NEG, "#h-to-0-position", {
|
||||
enabled: true,
|
||||
autoRotate: false,
|
||||
segments: [],
|
||||
});
|
||||
const reEnabled = parseGsapScriptAcornForWrite(enabled);
|
||||
const id = reEnabled?.located[0]?.id ?? "";
|
||||
const disabled = setArcPathInScript(enabled, id, {
|
||||
enabled: false,
|
||||
autoRotate: false,
|
||||
segments: [],
|
||||
});
|
||||
// The negative destination must come back — the UnaryExpression bug lost it.
|
||||
expect(disabled).toContain("x: -120");
|
||||
expect(disabled).toContain("y: -40");
|
||||
expect(disabled).not.toContain("motionPath");
|
||||
});
|
||||
});
|
||||
|
||||
// ── #7 — updating ease on a keyframe tween routes to easeEach, not top-level ──
|
||||
|
||||
describe("#7 — ease update on a keyframe tween targets keyframes.easeEach", () => {
|
||||
const KF = `var tl = gsap.timeline({ paused: true });
|
||||
tl.to(".a", { keyframes: { "0%": { x: 0 }, "100%": { x: 100 } }, duration: 1, ease: "none" }, 0);`;
|
||||
|
||||
it("writes easeEach (per-keyframe), not a no-op top-level ease", () => {
|
||||
const id = parseGsapScriptAcornForWrite(KF)?.located[0]?.id ?? "";
|
||||
const out = updateAnimationInScript(KF, id, { ease: "power2.inOut" });
|
||||
expect(out).toContain('easeEach: "power2.inOut"');
|
||||
// The original top-level `ease: "none"` is untouched (no second top-level ease).
|
||||
expect((out.match(/ease: "power2.inOut"/g) ?? []).length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── #8 — convertToKeyframes preserves builtin vars like `delay` ──
|
||||
|
||||
describe("#8 — convertToKeyframes keeps delay (was dropped, shifting start time)", () => {
|
||||
const DELAY = `var tl = gsap.timeline({ paused: true });
|
||||
tl.to(".a", { x: 100, duration: 1, delay: 0.3 }, 0);`;
|
||||
|
||||
it("preserves delay on the converted vars object", () => {
|
||||
const id = parseGsapScriptAcornForWrite(DELAY)?.located[0]?.id ?? "";
|
||||
const out = convertToKeyframesFromScript(DELAY, id);
|
||||
expect(out).toContain("keyframes:");
|
||||
expect(out).toContain("delay: 0.3"); // was lost → tween started 0.3s early
|
||||
expect(out).toContain("duration: 1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseGsapScriptAcorn } from "./gsapParserAcorn.js";
|
||||
import {
|
||||
updateAnimationInScript,
|
||||
addAnimationToScript,
|
||||
removeAnimationFromScript,
|
||||
addKeyframeToScript,
|
||||
removeAllKeyframesFromScript,
|
||||
} from "./gsapWriterAcorn.js";
|
||||
|
||||
// U3: edit/add/delete tweens on a timeline authored inline as
|
||||
// `window.__timelines["scene"] = gsap.timeline()`, emitting the member form.
|
||||
|
||||
const inlineSrc = `window.__timelines = window.__timelines || {};
|
||||
window.__timelines["scene"] = gsap.timeline({ paused: true });
|
||||
window.__timelines["scene"].to("#a", { x: 100, duration: 1 }, 0);
|
||||
window.__timelines["scene"].to("#b", { y: 50, duration: 1 }, 0.5);`;
|
||||
|
||||
describe("inline timeline assignment — write", () => {
|
||||
it("edits an existing inline tween's value in place", () => {
|
||||
const id = parseGsapScriptAcorn(inlineSrc).animations[0]!.id;
|
||||
const out = updateAnimationInScript(inlineSrc, id, { properties: { x: 200 } });
|
||||
expect(out).toContain('window.__timelines["scene"].to("#a"');
|
||||
expect(out).toContain("200");
|
||||
const reread = parseGsapScriptAcorn(out);
|
||||
expect(reread.animations).toHaveLength(2);
|
||||
expect(reread.unsupportedTimelinePattern).toBeFalsy();
|
||||
});
|
||||
|
||||
it("adds a new tween in the member form", () => {
|
||||
const { script: out } = addAnimationToScript(inlineSrc, {
|
||||
method: "to",
|
||||
targetSelector: "#c",
|
||||
properties: { opacity: 1 },
|
||||
position: 1,
|
||||
duration: 1,
|
||||
});
|
||||
expect(out).toContain('window.__timelines["scene"].to("#c"');
|
||||
expect(parseGsapScriptAcorn(out).animations).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("removes an inline tween, leaving the rest", () => {
|
||||
const id = parseGsapScriptAcorn(inlineSrc).animations[1]!.id;
|
||||
const out = removeAnimationFromScript(inlineSrc, id);
|
||||
expect(out).not.toContain('"#b"');
|
||||
expect(parseGsapScriptAcorn(out).animations).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("preserves single-quote member form on write", () => {
|
||||
const sq = `window.__timelines = window.__timelines || {};
|
||||
window.__timelines['scene'] = gsap.timeline();
|
||||
window.__timelines['scene'].to('#a', { x: 1, duration: 1 }, 0);`;
|
||||
const id = parseGsapScriptAcorn(sq).animations[0]!.id;
|
||||
const out = updateAnimationInScript(sq, id, { properties: { x: 9 } });
|
||||
expect(out).toContain("window.__timelines['scene']");
|
||||
expect(parseGsapScriptAcorn(out).animations).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("converts an inline tween to keyframes by adding one (the delete-all-keyframes bug area)", () => {
|
||||
const id = parseGsapScriptAcorn(inlineSrc).animations[0]!.id;
|
||||
const out = addKeyframeToScript(inlineSrc, id, 50, { x: 150 });
|
||||
expect(out).toContain("keyframes");
|
||||
expect(out).toContain('window.__timelines["scene"]');
|
||||
expect(parseGsapScriptAcorn(out).unsupportedTimelinePattern).toBeFalsy();
|
||||
});
|
||||
|
||||
it("removes all keyframes from an inline keyframed tween", () => {
|
||||
const kf = `window.__timelines = window.__timelines || {};
|
||||
window.__timelines["scene"] = gsap.timeline();
|
||||
window.__timelines["scene"].to("#a", { keyframes: { "0%": { x: 0 }, "100%": { x: 100 } }, duration: 1 }, 0);`;
|
||||
const id = parseGsapScriptAcorn(kf).animations[0]!.id;
|
||||
const out = removeAllKeyframesFromScript(kf, id);
|
||||
expect(out).not.toContain("keyframes");
|
||||
// Static hold (gsap.set equivalent): zero duration + immediateRender so the
|
||||
// element does not re-animate after collapse.
|
||||
const anim = parseGsapScriptAcorn(out).animations[0]!;
|
||||
expect(anim.duration).toBe(0);
|
||||
expect(anim.extras?.immediateRender).toBe("__raw:true");
|
||||
});
|
||||
|
||||
it("adds the first tween to an empty inline timeline", () => {
|
||||
const empty = `window.__timelines = window.__timelines || {};
|
||||
window.__timelines["scene"] = gsap.timeline({ paused: true });`;
|
||||
const { script: out } = addAnimationToScript(empty, {
|
||||
method: "to",
|
||||
targetSelector: "#a",
|
||||
properties: { x: 10 },
|
||||
position: 0,
|
||||
duration: 1,
|
||||
});
|
||||
expect(out).toContain('window.__timelines["scene"].to("#a"');
|
||||
expect(parseGsapScriptAcorn(out).animations).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("no-op write is stable (read → re-emit same → re-read equal count)", () => {
|
||||
const parsed = parseGsapScriptAcorn(inlineSrc);
|
||||
const id = parsed.animations[0]!.id;
|
||||
const out = updateAnimationInScript(inlineSrc, id, {
|
||||
properties: parsed.animations[0]!.properties,
|
||||
});
|
||||
expect(parseGsapScriptAcorn(out).animations).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("no duplicate vars keys on rewrite", () => {
|
||||
const setSrc = `window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.set("#a", { z: 0, rotationX: 5, immediateRender: true }, 0);
|
||||
window.__timelines["main"] = tl;`;
|
||||
|
||||
it("update with immediateRender riding in newProps emits the key once", () => {
|
||||
const id = parseGsapScriptAcorn(setSrc).animations[0]!.id;
|
||||
// Studio paths that build props off live tween vars carry the flag along.
|
||||
const out = updateAnimationInScript(setSrc, id, {
|
||||
properties: { z: 0, rotationX: 9, immediateRender: "__raw:true" },
|
||||
});
|
||||
expect(out).toContain("rotationX: 9");
|
||||
expect(out.match(/immediateRender/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("addAnimationToScript dedupes a key present in both properties and extras", () => {
|
||||
const { script: out } = addAnimationToScript(setSrc, {
|
||||
targetSelector: "#b",
|
||||
method: "set",
|
||||
position: 0,
|
||||
properties: { scale: 1, immediateRender: "__raw:true" },
|
||||
extras: { immediateRender: "__raw:true" },
|
||||
});
|
||||
const setB = out.split("\n").find((l) => l.includes('"#b"'));
|
||||
expect(setB?.match(/immediateRender/g)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,210 @@
|
||||
// fallow-ignore-file code-duplication
|
||||
/**
|
||||
* Differential parity test: acorn writer vs recast writer for addKeyframeToScript.
|
||||
*
|
||||
* The SDK uses the acorn (magic-string) writer; the server uses the recast
|
||||
* writer. An SDK-written keyframe op must produce a GSAP timeline whose parsed
|
||||
* keyframe array matches the recast-written one, otherwise newly-added props
|
||||
* snap instead of tween and stale `_auto` endpoints persist.
|
||||
*
|
||||
* We compare the *parsed keyframe arrays* (not byte-for-byte source) because the
|
||||
* two writers format differently (recast pretty-prints, acorn splices).
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { addKeyframeToScript as addAcorn } from "./gsapWriterAcorn.js";
|
||||
import { addKeyframeToScript as addRecast, parseGsapScript } from "./gsapParser.js";
|
||||
|
||||
// These fixtures hold exactly one tween. We look it up by index rather than by
|
||||
// id because the stable id is content-derived: adding/backfilling a property
|
||||
// changes the id, so a hardcoded lookup would spuriously return null.
|
||||
function keyframesOf(script: string) {
|
||||
const parsed = parseGsapScript(script);
|
||||
const anim = parsed.animations[0];
|
||||
const kf = anim?.keyframes;
|
||||
if (!kf || kf.format !== "percentage") return null;
|
||||
return kf.keyframes
|
||||
.slice()
|
||||
.sort((a, b) => a.percentage - b.percentage)
|
||||
.map((k) => ({ percentage: k.percentage, properties: k.properties, ease: k.ease }));
|
||||
}
|
||||
|
||||
// Script whose 0% / 100% endpoints carry the synthetic `_auto: 1` marker the
|
||||
// parser emits for auto-derived endpoints.
|
||||
const AUTO_SCRIPT = `\
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.to("#box", { keyframes: { "0%": { opacity: 1, _auto: 1 }, "100%": { opacity: 0, _auto: 1 } }, duration: 0.5 }, 0.2);
|
||||
window.__timelines["t"] = tl;`;
|
||||
|
||||
// Script with three plain keyframes (no _auto), used for the backfill case.
|
||||
const PLAIN_SCRIPT = `\
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.to("#box", { keyframes: { "0%": { opacity: 0 }, "50%": { opacity: 0.7 }, "100%": { opacity: 1 } }, duration: 0.5 }, 0.2);
|
||||
window.__timelines["t"] = tl;`;
|
||||
|
||||
// _auto endpoints with an interior 25% plain keyframe (0/25/100). Exercises the
|
||||
// "interior keyframe adjacent to a 100% _auto endpoint" path that crashed.
|
||||
const AUTO_THREE_SCRIPT = `\
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.to("#box", { keyframes: { "0%": { opacity: 1, _auto: 1 }, "25%": { opacity: 0.5 }, "100%": { opacity: 0, _auto: 1 } }, duration: 0.5 }, 0.2);
|
||||
window.__timelines["t"] = tl;`;
|
||||
|
||||
// An existing keyframe carrying extra props + a per-keyframe ease. Re-touching
|
||||
// one prop must MERGE (preserve the others + the ease), not replace wholesale.
|
||||
const MERGE_SCRIPT = `\
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.to("#box", { keyframes: { "0%": { opacity: 0, x: 0 }, "50%": { opacity: 0.7, x: 30, scale: 2, ease: "power2.in" }, "100%": { opacity: 1, x: 60 } }, duration: 0.5 }, 0.2);
|
||||
window.__timelines["t"] = tl;`;
|
||||
|
||||
// An existing keyframe keyed "50.0%" (not byte-equal to "50%").
|
||||
const DECIMAL_KEY_SCRIPT = `\
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.to("#box", { keyframes: { "0%": { opacity: 0 }, "50.0%": { opacity: 0.7 }, "100%": { opacity: 1 } }, duration: 0.5 }, 0.2);
|
||||
window.__timelines["t"] = tl;`;
|
||||
|
||||
// Flat tweens (no keyframes object) — the first keyframe-add must convert them.
|
||||
const FLAT_TO_SCRIPT = `\
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.to("#box", { opacity: 0.5, x: 100, duration: 0.5, ease: "power2.out" }, 0.2);
|
||||
window.__timelines["t"] = tl;`;
|
||||
|
||||
const FLAT_FROMTO_SCRIPT = `\
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.fromTo("#box", { opacity: 0, y: 20 }, { opacity: 1, y: 0, duration: 0.5 }, 0.2);
|
||||
window.__timelines["t"] = tl;`;
|
||||
|
||||
function animId(script: string): string {
|
||||
const id = parseGsapScript(script).animations[0]?.id;
|
||||
if (!id) throw new Error("no animation in fixture");
|
||||
return id;
|
||||
}
|
||||
|
||||
describe("acorn↔recast addKeyframeToScript parity", () => {
|
||||
it("rewrites an _auto 100% endpoint when the inserted keyframe is its left neighbor", () => {
|
||||
const id = animId(AUTO_SCRIPT);
|
||||
const props = { opacity: 0.3, x: 50 };
|
||||
const recast = addRecast(AUTO_SCRIPT, id, 60, props);
|
||||
const acorn = addAcorn(AUTO_SCRIPT, id, 60, props);
|
||||
expect(keyframesOf(acorn)).toEqual(keyframesOf(recast));
|
||||
});
|
||||
|
||||
it("rewrites an _auto 0% endpoint when the inserted keyframe is its right neighbor", () => {
|
||||
const id = animId(AUTO_SCRIPT);
|
||||
const props = { opacity: 0.8, scale: 2 };
|
||||
const recast = addRecast(AUTO_SCRIPT, id, 40, props);
|
||||
const acorn = addAcorn(AUTO_SCRIPT, id, 40, props);
|
||||
expect(keyframesOf(acorn)).toEqual(keyframesOf(recast));
|
||||
});
|
||||
|
||||
it("backfills a NEW property into the other keyframes with its default value", () => {
|
||||
const id = animId(PLAIN_SCRIPT);
|
||||
const props = { opacity: 0.3, x: 120 };
|
||||
const backfill = { opacity: 1, x: 0 };
|
||||
const recast = addRecast(PLAIN_SCRIPT, id, 25, props, undefined, backfill);
|
||||
const acorn = addAcorn(PLAIN_SCRIPT, id, 25, props, undefined, backfill);
|
||||
expect(keyframesOf(acorn)).toEqual(keyframesOf(recast));
|
||||
});
|
||||
|
||||
it("no backfill arg → matches recast with no backfill (new prop left absent)", () => {
|
||||
const id = animId(PLAIN_SCRIPT);
|
||||
const props = { opacity: 0.3, x: 120 };
|
||||
const recast = addRecast(PLAIN_SCRIPT, id, 25, props);
|
||||
const acorn = addAcorn(PLAIN_SCRIPT, id, 25, props);
|
||||
expect(keyframesOf(acorn)).toEqual(keyframesOf(recast));
|
||||
});
|
||||
|
||||
it("plain insert in sorted order stays at parity", () => {
|
||||
const id = animId(PLAIN_SCRIPT);
|
||||
const props = { opacity: 0.3 };
|
||||
const recast = addRecast(PLAIN_SCRIPT, id, 25, props);
|
||||
const acorn = addAcorn(PLAIN_SCRIPT, id, 25, props);
|
||||
expect(keyframesOf(acorn)).toEqual(keyframesOf(recast));
|
||||
});
|
||||
|
||||
// ── Bug 1: crash — _auto endpoint sync + backfill of a new prop together ──────
|
||||
it("syncs an _auto 100% endpoint AND backfills a new prop (2-endpoint, the crash)", () => {
|
||||
const id = animId(AUTO_SCRIPT);
|
||||
const props = { opacity: 0.3, x: 50 };
|
||||
const backfill = { opacity: 1, x: 0 };
|
||||
const recast = addRecast(AUTO_SCRIPT, id, 60, props, undefined, backfill);
|
||||
const acorn = addAcorn(AUTO_SCRIPT, id, 60, props, undefined, backfill);
|
||||
expect(keyframesOf(acorn)).toEqual(keyframesOf(recast));
|
||||
});
|
||||
|
||||
it("syncs _auto endpoint AND backfills a new prop (0/25/100 interior, the crash)", () => {
|
||||
const id = animId(AUTO_THREE_SCRIPT);
|
||||
const props = { opacity: 0.4, x: 80 };
|
||||
const backfill = { opacity: 1, x: 0 };
|
||||
const recast = addRecast(AUTO_THREE_SCRIPT, id, 60, props, undefined, backfill);
|
||||
const acorn = addAcorn(AUTO_THREE_SCRIPT, id, 60, props, undefined, backfill);
|
||||
expect(keyframesOf(acorn)).toEqual(keyframesOf(recast));
|
||||
});
|
||||
|
||||
// ── Bug 2: no-comma corruption — backfill ≥2 props into an empty {} keyframe ──
|
||||
it("backfills ≥2 new props into an empty {} keyframe without dropping the comma", () => {
|
||||
const EMPTY_KF = `\
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.to("#box", { keyframes: { "0%": {}, "100%": { x: 100, y: 50 } }, duration: 0.5 }, 0.2);
|
||||
window.__timelines["t"] = tl;`;
|
||||
const id = animId(EMPTY_KF);
|
||||
const props = { x: 40, y: 20 };
|
||||
const backfill = { x: 0, y: 0 };
|
||||
const recast = addRecast(EMPTY_KF, id, 50, props, undefined, backfill);
|
||||
const acorn = addAcorn(EMPTY_KF, id, 50, props, undefined, backfill);
|
||||
expect(keyframesOf(acorn)).toEqual(keyframesOf(recast));
|
||||
});
|
||||
|
||||
// ── Bug 4 + 7: merge — preserve untouched props AND existing ease ──────────────
|
||||
it("merges new props over an existing keyframe, preserving its other props + ease", () => {
|
||||
const id = animId(MERGE_SCRIPT);
|
||||
const props = { opacity: 0.9 };
|
||||
const recast = addRecast(MERGE_SCRIPT, id, 50, props);
|
||||
const acorn = addAcorn(MERGE_SCRIPT, id, 50, props);
|
||||
expect(keyframesOf(acorn)).toEqual(keyframesOf(recast));
|
||||
});
|
||||
|
||||
// ── Bug 5: convert-flat — first keyframe-add on a flat tween ──────────────────
|
||||
it("converts a flat to() tween to keyframes on the first keyframe add", () => {
|
||||
const id = animId(FLAT_TO_SCRIPT);
|
||||
const props = { opacity: 0.8 };
|
||||
const backfill = { opacity: 1 };
|
||||
const recast = addRecast(FLAT_TO_SCRIPT, id, 50, props, undefined, backfill);
|
||||
const acorn = addAcorn(FLAT_TO_SCRIPT, id, 50, props, undefined, backfill);
|
||||
expect(keyframesOf(acorn)).toEqual(keyframesOf(recast));
|
||||
});
|
||||
|
||||
it("converts a flat fromTo() tween to keyframes on the first keyframe add", () => {
|
||||
const id = animId(FLAT_FROMTO_SCRIPT);
|
||||
const props = { y: 10 };
|
||||
const backfill = { y: 0 };
|
||||
const recast = addRecast(FLAT_FROMTO_SCRIPT, id, 50, props, undefined, backfill);
|
||||
const acorn = addAcorn(FLAT_FROMTO_SCRIPT, id, 50, props, undefined, backfill);
|
||||
expect(keyframesOf(acorn)).toEqual(keyframesOf(recast));
|
||||
});
|
||||
|
||||
// ── Bug 3: existing "50.0%" key, add at 50 (non-byte-equal % key) ─────────────
|
||||
it("merges into a non-byte-equal '50.0%' key when adding at 50", () => {
|
||||
const id = animId(DECIMAL_KEY_SCRIPT);
|
||||
const props = { opacity: 0.9 };
|
||||
const recast = addRecast(DECIMAL_KEY_SCRIPT, id, 50, props);
|
||||
const acorn = addAcorn(DECIMAL_KEY_SCRIPT, id, 50, props);
|
||||
expect(keyframesOf(acorn)).toEqual(keyframesOf(recast));
|
||||
});
|
||||
|
||||
// ── Bug 8: %-tolerance — existing 50, add 51 should MERGE (PCT_TOLERANCE=2) ────
|
||||
it("treats a near-coincident percentage (50 vs 51) as the same keyframe (merge)", () => {
|
||||
const id = animId(PLAIN_SCRIPT);
|
||||
const props = { opacity: 0.9 };
|
||||
const recast = addRecast(PLAIN_SCRIPT, id, 51, props);
|
||||
const acorn = addAcorn(PLAIN_SCRIPT, id, 51, props);
|
||||
expect(keyframesOf(acorn)).toEqual(keyframesOf(recast));
|
||||
});
|
||||
|
||||
// ── Bug 7: adding ONTO a 0/100 _auto endpoint preserves the _auto marker ──────
|
||||
it("preserves the _auto marker when adding a prop directly onto a 0% _auto endpoint", () => {
|
||||
const id = animId(AUTO_SCRIPT);
|
||||
const props = { x: 25 };
|
||||
const recast = addRecast(AUTO_SCRIPT, id, 0, props);
|
||||
const acorn = addAcorn(AUTO_SCRIPT, id, 0, props);
|
||||
expect(keyframesOf(acorn)).toEqual(keyframesOf(recast));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,648 @@
|
||||
// fallow-ignore-file code-duplication
|
||||
/**
|
||||
* Recast-vs-acorn GSAP-writer differential suite (WS-3.F cutover gate).
|
||||
*
|
||||
* The SDK's browser-safe acorn writer (gsapWriterAcorn.ts) must produce output
|
||||
* equivalent to the server's recast writer (gsapParser.ts) for every write op,
|
||||
* so that making the acorn writer authoritative (retiring recast) is behavior-
|
||||
* preserving. The two formatters differ (recast pretty-prints, acorn splices),
|
||||
* so we never compare bytes — we apply each op via BOTH writers, parse both
|
||||
* outputs with the shared `parseGsapScriptAcorn`, and assert the resulting
|
||||
* animation models match structurally.
|
||||
*
|
||||
* Until this suite, only `addKeyframeToScript` had a true differential test
|
||||
* (gsapWriterParity.acorn.test.ts). This file extends parity coverage to the
|
||||
* five previously standalone-only ops:
|
||||
* updateAnimationInScript, addAnimationToScript, removeAnimationFromScript,
|
||||
* updateKeyframeInScript, removeKeyframeFromScript.
|
||||
* Plus correctness tests for the acorn-only label ops (addLabelToScript /
|
||||
* removeLabelFromScript), which have no recast oracle to diff against.
|
||||
*
|
||||
* The harness (`runParity`, `modelOf`) is exported so the follow-up WS-3 op-PR
|
||||
* workflow can reuse it to gate each cut-over op.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
updateAnimationInScript as updateAnimAcorn,
|
||||
addAnimationToScript as addAnimAcorn,
|
||||
removeAnimationFromScript as removeAnimAcorn,
|
||||
updateKeyframeInScript as updateKfAcorn,
|
||||
removeKeyframeFromScript as removeKfAcorn,
|
||||
addLabelToScript,
|
||||
removeLabelFromScript,
|
||||
} from "./gsapWriterAcorn.js";
|
||||
import {
|
||||
updateAnimationInScript as updateAnimRecast,
|
||||
addAnimationToScript as addAnimRecast,
|
||||
removeAnimationFromScript as removeAnimRecast,
|
||||
updateKeyframeInScript as updateKfRecast,
|
||||
removeKeyframeFromScript as removeKfRecast,
|
||||
parseGsapScript,
|
||||
} from "./gsapParser.js";
|
||||
import { parseGsapScriptAcorn } from "./gsapParserAcorn.js";
|
||||
import type { GsapAnimation } from "./gsapSerialize.js";
|
||||
|
||||
// ── Reusable differential harness (exported for the WS-3 op-PR workflow) ───────
|
||||
|
||||
/**
|
||||
* Fields that are incidental metadata — derived per-parse rather than authored —
|
||||
* and so must be excluded from a structural comparison: stable ids are content-
|
||||
* derived (and recast's addAnimation id is a `Date.now()` placeholder), and the
|
||||
* rest are computed analysis (resolved start, group classification, provenance).
|
||||
*/
|
||||
const IGNORED_FIELDS = new Set<keyof GsapAnimation | string>([
|
||||
"id",
|
||||
"resolvedStart",
|
||||
"implicitPosition",
|
||||
"propertyGroup",
|
||||
"provenance",
|
||||
"hasUnresolvedKeyframes",
|
||||
"hasUnresolvedSelector",
|
||||
]);
|
||||
|
||||
type NormalizedAnimation = Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* Parse a GSAP script and reduce each animation to its authored shape: target,
|
||||
* method, position, properties, fromProperties, duration, ease, extras,
|
||||
* keyframes — dropping per-parse metadata. Both writers' outputs go through this
|
||||
* SAME parser, so any model difference is a genuine writer divergence, not a
|
||||
* parser artifact.
|
||||
*/
|
||||
export function modelOf(script: string): NormalizedAnimation[] {
|
||||
return parseGsapScriptAcorn(script).animations.map((anim) => {
|
||||
const out: NormalizedAnimation = {};
|
||||
for (const [key, value] of Object.entries(anim)) {
|
||||
if (IGNORED_FIELDS.has(key) || value === undefined) continue;
|
||||
out[key] = value;
|
||||
}
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an op via BOTH writers and assert the parsed animation models match.
|
||||
* `recast`/`acorn` each receive the original script and must return the rewritten
|
||||
* script. Returns the recast-written script so callers can chain ops.
|
||||
*/
|
||||
export function runParity(
|
||||
script: string,
|
||||
recast: (s: string) => string,
|
||||
acorn: (s: string) => string,
|
||||
): string {
|
||||
const recastOut = recast(script);
|
||||
const acornOut = acorn(script);
|
||||
expect(modelOf(acornOut), "acorn model must equal recast model").toEqual(modelOf(recastOut));
|
||||
return recastOut;
|
||||
}
|
||||
|
||||
/** The id of the i-th animation in a script (recast parser — the op oracle). */
|
||||
function idAt(script: string, index = 0): string {
|
||||
const id = parseGsapScript(script).animations[index]?.id;
|
||||
if (!id) throw new Error(`no animation at index ${index} in fixture`);
|
||||
return id;
|
||||
}
|
||||
|
||||
// ── Corpus ─────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Real registry scripts (literal-tween portions extracted verbatim from
|
||||
// registry/blocks and registry/components, trimmed to the editable tweens) plus
|
||||
// synthetic scripts covering breadth the single-op test lacks: to/from/fromTo,
|
||||
// multi-tween, keyframes, labels, numeric + label-relative + symbolic positions,
|
||||
// stagger/repeat/yoyo extras, and sub-composition selectors.
|
||||
|
||||
// REAL — registry/blocks/macos-notification: two plain .to() tweens, numeric pos.
|
||||
const REAL_MACOS = `\
|
||||
window.__timelines = window.__timelines || {};
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.to("#notification", { x: 0, opacity: 1, duration: 0.5, ease: "power3.out" }, 0.2);
|
||||
tl.to("#notification", { x: 420, opacity: 0, duration: 0.3, ease: "power3.in" }, 4.2);
|
||||
window.__timelines["macos-notification"] = tl;`;
|
||||
|
||||
// REAL — registry/blocks/flowchart: multi .to() with strokeDashoffset + back ease.
|
||||
const REAL_FLOWCHART = `\
|
||||
window.__timelines = window.__timelines || {};
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.to("#node-root", { scale: 1, duration: 0.4, ease: "back.out(2)" }, 0.2);
|
||||
tl.to("#node-yes", { scale: 1, duration: 0.3, ease: "back.out(2)" }, 0.5);
|
||||
tl.to("#path-1-L", { strokeDashoffset: 0, duration: 0.6, ease: "power2.inOut" }, 0.8);
|
||||
window.__timelines["flowchart"] = tl;`;
|
||||
|
||||
// REAL — registry/components/caption-kinetic-slam: .fromTo() entrances + .to() exit.
|
||||
const REAL_CAPTION = `\
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.fromTo("#kt-w-0", { y: -120, opacity: 0 }, { y: 0, opacity: 1, duration: 0.22, ease: "back.out(1.7)" }, 0.5);
|
||||
tl.fromTo("#kt-w-1", { x: -300, opacity: 0 }, { x: 0, opacity: 1, duration: 0.2, ease: "expo.out" }, 1.2);
|
||||
tl.to("#kt-w-0", { opacity: 0, duration: 0.1, ease: "power2.in" }, 2.5);
|
||||
window.__timelines["caption-kinetic-slam"] = tl;`;
|
||||
|
||||
// SYNTH — single plain to().
|
||||
const SYN_SINGLE = `\
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.to("#hero", { opacity: 1, x: 100, duration: 0.5, ease: "power3.out" }, 0.2);
|
||||
window.__timelines["t"] = tl;`;
|
||||
|
||||
// SYNTH — to + from + fromTo, mixed positions (numeric, symbolic "<", numeric).
|
||||
const SYN_MIXED_METHODS = `\
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.from("#title", { opacity: 0, y: 30, duration: 0.6, ease: "power2.out" }, 0);
|
||||
tl.to("#title", { opacity: 0, duration: 0.4 }, 1.2);
|
||||
tl.fromTo(".card", { scale: 0.8, opacity: 0 }, { scale: 1, opacity: 1, duration: 0.5 }, "<");
|
||||
window.__timelines["t"] = tl;`;
|
||||
|
||||
// SYNTH — extras: stagger / repeat / yoyo / repeatDelay must survive every op.
|
||||
const SYN_EXTRAS = `\
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.to(".dot", { y: -20, duration: 0.3, stagger: 0.08, repeat: 2, yoyo: true, repeatDelay: 0.1 }, 0);
|
||||
tl.from("#panel", { opacity: 0, duration: 0.5, ease: "sine.out" }, 0.4);
|
||||
window.__timelines["t"] = tl;`;
|
||||
|
||||
// SYNTH — labels + label-relative positions ("intro", "intro+=0.3").
|
||||
const SYN_LABELED = `\
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.addLabel("intro", 0);
|
||||
tl.to("#a", { opacity: 1, duration: 0.5 }, "intro");
|
||||
tl.to("#b", { opacity: 1, duration: 0.5 }, "intro+=0.3");
|
||||
window.__timelines["t"] = tl;`;
|
||||
|
||||
// SYNTH — sub-composition / nested selectors (scoped descendant + attribute).
|
||||
const SYN_NESTED = `\
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.to("#scene-2 .headline", { y: 0, opacity: 1, duration: 0.5 }, 0);
|
||||
tl.from('[data-hf-id="scene-2"] .sub', { opacity: 0, duration: 0.4 }, 0.3);
|
||||
window.__timelines["t"] = tl;`;
|
||||
|
||||
// SYNTH — percentage keyframes (3 kfs).
|
||||
const SYN_KF3 = `\
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.to("#box", { keyframes: { "0%": { opacity: 0 }, "50%": { opacity: 0.7 }, "100%": { opacity: 1 } }, duration: 0.5 }, 0.2);
|
||||
window.__timelines["t"] = tl;`;
|
||||
|
||||
// SYNTH — percentage keyframes (2 kfs) — removal collapses to flat.
|
||||
const SYN_KF2 = `\
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.to("#box", { keyframes: { "0%": { opacity: 0, x: 0 }, "100%": { opacity: 1, x: 40 } }, duration: 0.5, delay: 0.1 }, 0.2);
|
||||
window.__timelines["t"] = tl;`;
|
||||
|
||||
// SYNTH — keyframes carrying per-keyframe ease + easeEach.
|
||||
const SYN_KF_EASE = `\
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.to("#box", { keyframes: { "0%": { y: 0 }, "50%": { y: 30, ease: "power2.in" }, "100%": { y: 0 }, easeEach: "power1.inOut" }, duration: 0.8 }, 0);
|
||||
window.__timelines["t"] = tl;`;
|
||||
|
||||
// SYNTH — chained tweens (tl.from(...).from(...)) for chain-link removal.
|
||||
const SYN_CHAIN = `\
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.from(".a", { opacity: 0, duration: 0.5 }, 0)
|
||||
.from(".b", { opacity: 0, duration: 0.3 }, 0.5)
|
||||
.to(".a", { x: 10, duration: 0.2 }, 1);
|
||||
window.__timelines["t"] = tl;`;
|
||||
|
||||
// ── 1. updateAnimationInScript ──────────────────────────────────────────────────
|
||||
|
||||
describe("parity — updateAnimationInScript", () => {
|
||||
it("updates duration + ease on a real two-tween block (macos)", () => {
|
||||
const id = idAt(REAL_MACOS, 1);
|
||||
const u = { duration: 0.9, ease: "power1.in" };
|
||||
runParity(
|
||||
REAL_MACOS,
|
||||
(s) => updateAnimRecast(s, id, u),
|
||||
(s) => updateAnimAcorn(s, id, u),
|
||||
);
|
||||
});
|
||||
|
||||
it("updates duration on a real .to() with a back.out ease (flowchart)", () => {
|
||||
const id = idAt(REAL_FLOWCHART, 0);
|
||||
const u = { duration: 0.7 };
|
||||
runParity(
|
||||
REAL_FLOWCHART,
|
||||
(s) => updateAnimRecast(s, id, u),
|
||||
(s) => updateAnimAcorn(s, id, u),
|
||||
);
|
||||
});
|
||||
|
||||
it("REPLACES the editable property set (props absent from update are dropped)", () => {
|
||||
const id = idAt(SYN_MIXED_METHODS, 0);
|
||||
const u = { properties: { x: 50, rotation: 10 } };
|
||||
runParity(
|
||||
SYN_MIXED_METHODS,
|
||||
(s) => updateAnimRecast(s, id, u),
|
||||
(s) => updateAnimAcorn(s, id, u),
|
||||
);
|
||||
});
|
||||
|
||||
it("REPLACES fromTo from-vars (props absent from update are dropped)", () => {
|
||||
const id = idAt(SYN_MIXED_METHODS, 2);
|
||||
const u = { fromProperties: { scale: 0.5 } };
|
||||
runParity(
|
||||
SYN_MIXED_METHODS,
|
||||
(s) => updateAnimRecast(s, id, u),
|
||||
(s) => updateAnimAcorn(s, id, u),
|
||||
);
|
||||
});
|
||||
|
||||
it("replaces props while preserving extras (stagger/repeat/yoyo)", () => {
|
||||
const id = idAt(SYN_EXTRAS, 0);
|
||||
const u = { properties: { y: -40 } };
|
||||
runParity(
|
||||
SYN_EXTRAS,
|
||||
(s) => updateAnimRecast(s, id, u),
|
||||
(s) => updateAnimAcorn(s, id, u),
|
||||
);
|
||||
});
|
||||
|
||||
it("updates props + duration + ease together in one op", () => {
|
||||
const id = idAt(SYN_SINGLE, 0);
|
||||
const u = { properties: { x: 9 }, duration: 1.1, ease: "sine.in" };
|
||||
runParity(
|
||||
SYN_SINGLE,
|
||||
(s) => updateAnimRecast(s, id, u),
|
||||
(s) => updateAnimAcorn(s, id, u),
|
||||
);
|
||||
});
|
||||
|
||||
it("changes numeric position to a label-relative string position", () => {
|
||||
const id = idAt(SYN_MIXED_METHODS, 0);
|
||||
const u = { position: "intro+=0.5" };
|
||||
runParity(
|
||||
SYN_MIXED_METHODS,
|
||||
(s) => updateAnimRecast(s, id, u),
|
||||
(s) => updateAnimAcorn(s, id, u),
|
||||
);
|
||||
});
|
||||
|
||||
it("changes a symbolic '<' position to a numeric position", () => {
|
||||
const id = idAt(SYN_MIXED_METHODS, 2);
|
||||
const u = { position: 3 };
|
||||
runParity(
|
||||
SYN_MIXED_METHODS,
|
||||
(s) => updateAnimRecast(s, id, u),
|
||||
(s) => updateAnimAcorn(s, id, u),
|
||||
);
|
||||
});
|
||||
|
||||
it("updates a tween whose position is a label name (labeled fixture)", () => {
|
||||
const id = idAt(SYN_LABELED, 1);
|
||||
const u = { duration: 0.9 };
|
||||
runParity(
|
||||
SYN_LABELED,
|
||||
(s) => updateAnimRecast(s, id, u),
|
||||
(s) => updateAnimAcorn(s, id, u),
|
||||
);
|
||||
});
|
||||
|
||||
it("inserts ease when absent on a nested-selector tween", () => {
|
||||
const id = idAt(SYN_NESTED, 0);
|
||||
const u = { ease: "power3.out" };
|
||||
runParity(
|
||||
SYN_NESTED,
|
||||
(s) => updateAnimRecast(s, id, u),
|
||||
(s) => updateAnimAcorn(s, id, u),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 2. addAnimationToScript ──────────────────────────────────────────────────────
|
||||
|
||||
describe("parity — addAnimationToScript", () => {
|
||||
const add =
|
||||
(animation: Omit<GsapAnimation, "id">) =>
|
||||
(writer: (s: string, a: Omit<GsapAnimation, "id">) => { script: string }) =>
|
||||
(s: string) =>
|
||||
writer(s, animation).script;
|
||||
|
||||
it("appends a plain to() after the last real tween (macos)", () => {
|
||||
const anim: Omit<GsapAnimation, "id"> = {
|
||||
targetSelector: "#new",
|
||||
method: "to",
|
||||
position: 5,
|
||||
duration: 0.3,
|
||||
properties: { x: 100 },
|
||||
ease: "sine.in",
|
||||
};
|
||||
const build = add(anim);
|
||||
runParity(REAL_MACOS, build(addAnimRecast), build(addAnimAcorn));
|
||||
});
|
||||
|
||||
it("appends a fromTo() with extras (repeat/yoyo)", () => {
|
||||
const anim: Omit<GsapAnimation, "id"> = {
|
||||
targetSelector: "#x",
|
||||
method: "fromTo",
|
||||
position: 2,
|
||||
duration: 0.4,
|
||||
properties: { opacity: 1 },
|
||||
fromProperties: { opacity: 0 },
|
||||
extras: { repeat: 2, yoyo: true },
|
||||
};
|
||||
const build = add(anim);
|
||||
runParity(SYN_MIXED_METHODS, build(addAnimRecast), build(addAnimAcorn));
|
||||
});
|
||||
|
||||
it("appends a from() with a symbolic '<' position", () => {
|
||||
const anim: Omit<GsapAnimation, "id"> = {
|
||||
targetSelector: ".card",
|
||||
method: "from",
|
||||
position: "<",
|
||||
duration: 0.5,
|
||||
properties: { y: 20, opacity: 0 },
|
||||
};
|
||||
const build = add(anim);
|
||||
runParity(SYN_EXTRAS, build(addAnimRecast), build(addAnimAcorn));
|
||||
});
|
||||
|
||||
it("appends a tween with a label-relative position", () => {
|
||||
const anim: Omit<GsapAnimation, "id"> = {
|
||||
targetSelector: "#c",
|
||||
method: "to",
|
||||
position: "intro+=0.6",
|
||||
duration: 0.4,
|
||||
properties: { opacity: 1 },
|
||||
};
|
||||
const build = add(anim);
|
||||
runParity(SYN_LABELED, build(addAnimRecast), build(addAnimAcorn));
|
||||
});
|
||||
|
||||
it("appends a tween onto a chained-tween timeline", () => {
|
||||
const anim: Omit<GsapAnimation, "id"> = {
|
||||
targetSelector: ".c",
|
||||
method: "to",
|
||||
position: 1.5,
|
||||
duration: 0.3,
|
||||
properties: { scale: 1.2 },
|
||||
};
|
||||
const build = add(anim);
|
||||
runParity(SYN_CHAIN, build(addAnimRecast), build(addAnimAcorn));
|
||||
});
|
||||
|
||||
it("appends with a nested sub-composition selector", () => {
|
||||
const anim: Omit<GsapAnimation, "id"> = {
|
||||
targetSelector: "#scene-2 .footer",
|
||||
method: "to",
|
||||
position: 0.8,
|
||||
duration: 0.3,
|
||||
properties: { opacity: 1 },
|
||||
};
|
||||
const build = add(anim);
|
||||
runParity(SYN_NESTED, build(addAnimRecast), build(addAnimAcorn));
|
||||
});
|
||||
|
||||
it("inserts after the timeline decl when the script has no tweens", () => {
|
||||
const empty = `var tl = gsap.timeline({ paused: true });\nwindow.__timelines["t"] = tl;`;
|
||||
const anim: Omit<GsapAnimation, "id"> = {
|
||||
targetSelector: "#hero",
|
||||
method: "to",
|
||||
position: 0,
|
||||
duration: 0.5,
|
||||
properties: { opacity: 1 },
|
||||
};
|
||||
const build = add(anim);
|
||||
runParity(empty, build(addAnimRecast), build(addAnimAcorn));
|
||||
});
|
||||
});
|
||||
|
||||
// ── 3. removeAnimationFromScript ─────────────────────────────────────────────────
|
||||
|
||||
describe("parity — removeAnimationFromScript", () => {
|
||||
it("removes a standalone tween statement (real macos, first tween)", () => {
|
||||
const id = idAt(REAL_MACOS, 0);
|
||||
runParity(
|
||||
REAL_MACOS,
|
||||
(s) => removeAnimRecast(s, id),
|
||||
(s) => removeAnimAcorn(s, id),
|
||||
);
|
||||
});
|
||||
|
||||
it("removes the middle standalone tween of a 3-tween block (flowchart)", () => {
|
||||
const id = idAt(REAL_FLOWCHART, 1);
|
||||
runParity(
|
||||
REAL_FLOWCHART,
|
||||
(s) => removeAnimRecast(s, id),
|
||||
(s) => removeAnimAcorn(s, id),
|
||||
);
|
||||
});
|
||||
|
||||
it("removes the only tween (timeline left empty)", () => {
|
||||
const id = idAt(SYN_SINGLE, 0);
|
||||
runParity(
|
||||
SYN_SINGLE,
|
||||
(s) => removeAnimRecast(s, id),
|
||||
(s) => removeAnimAcorn(s, id),
|
||||
);
|
||||
});
|
||||
|
||||
it("removes a fromTo tween (real caption block)", () => {
|
||||
const id = idAt(REAL_CAPTION, 1);
|
||||
runParity(
|
||||
REAL_CAPTION,
|
||||
(s) => removeAnimRecast(s, id),
|
||||
(s) => removeAnimAcorn(s, id),
|
||||
);
|
||||
});
|
||||
|
||||
it("removes the inner-most chain link", () => {
|
||||
const id = idAt(SYN_CHAIN, 0);
|
||||
runParity(
|
||||
SYN_CHAIN,
|
||||
(s) => removeAnimRecast(s, id),
|
||||
(s) => removeAnimAcorn(s, id),
|
||||
);
|
||||
});
|
||||
|
||||
it("removes the outer-most chain link", () => {
|
||||
const id = idAt(SYN_CHAIN, 2);
|
||||
runParity(
|
||||
SYN_CHAIN,
|
||||
(s) => removeAnimRecast(s, id),
|
||||
(s) => removeAnimAcorn(s, id),
|
||||
);
|
||||
});
|
||||
|
||||
it("removes a labeled-position tween (label statement preserved)", () => {
|
||||
const id = idAt(SYN_LABELED, 0);
|
||||
runParity(
|
||||
SYN_LABELED,
|
||||
(s) => removeAnimRecast(s, id),
|
||||
(s) => removeAnimAcorn(s, id),
|
||||
);
|
||||
});
|
||||
|
||||
it("removes a tween carrying extras", () => {
|
||||
const id = idAt(SYN_EXTRAS, 0);
|
||||
runParity(
|
||||
SYN_EXTRAS,
|
||||
(s) => removeAnimRecast(s, id),
|
||||
(s) => removeAnimAcorn(s, id),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 4. updateKeyframeInScript ────────────────────────────────────────────────────
|
||||
|
||||
describe("parity — updateKeyframeInScript", () => {
|
||||
it("replaces the 50% keyframe value", () => {
|
||||
const id = idAt(SYN_KF3, 0);
|
||||
runParity(
|
||||
SYN_KF3,
|
||||
(s) => updateKfRecast(s, id, 50, { opacity: 0.5 }),
|
||||
(s) => updateKfAcorn(s, id, 50, { opacity: 0.5 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("replaces a keyframe value AND sets a per-keyframe ease", () => {
|
||||
const id = idAt(SYN_KF3, 0);
|
||||
runParity(
|
||||
SYN_KF3,
|
||||
(s) => updateKfRecast(s, id, 50, { opacity: 0.4 }, "power2.in"),
|
||||
(s) => updateKfAcorn(s, id, 50, { opacity: 0.4 }, "power2.in"),
|
||||
);
|
||||
});
|
||||
|
||||
it("replaces the endpoint (100%) keyframe value", () => {
|
||||
const id = idAt(SYN_KF3, 0);
|
||||
runParity(
|
||||
SYN_KF3,
|
||||
(s) => updateKfRecast(s, id, 100, { opacity: 0.9 }),
|
||||
(s) => updateKfAcorn(s, id, 100, { opacity: 0.9 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("replaces a keyframe that carried a per-keyframe ease (ease dropped when omitted)", () => {
|
||||
const id = idAt(SYN_KF_EASE, 0);
|
||||
runParity(
|
||||
SYN_KF_EASE,
|
||||
(s) => updateKfRecast(s, id, 50, { y: 25 }),
|
||||
(s) => updateKfAcorn(s, id, 50, { y: 25 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("replaces with multiple props on a keyframes-with-easeEach fixture", () => {
|
||||
const id = idAt(SYN_KF_EASE, 0);
|
||||
runParity(
|
||||
SYN_KF_EASE,
|
||||
(s) => updateKfRecast(s, id, 100, { y: 5, opacity: 1 }),
|
||||
(s) => updateKfAcorn(s, id, 100, { y: 5, opacity: 1 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 5. removeKeyframeFromScript ──────────────────────────────────────────────────
|
||||
|
||||
describe("parity — removeKeyframeFromScript", () => {
|
||||
it("removes the interior keyframe, leaving ≥2 (no collapse)", () => {
|
||||
const id = idAt(SYN_KF3, 0);
|
||||
runParity(
|
||||
SYN_KF3,
|
||||
(s) => removeKfRecast(s, id, 50),
|
||||
(s) => removeKfAcorn(s, id, 50),
|
||||
);
|
||||
});
|
||||
|
||||
it("collapses to a flat tween when removal leaves a single keyframe", () => {
|
||||
const id = idAt(SYN_KF2, 0);
|
||||
runParity(
|
||||
SYN_KF2,
|
||||
(s) => removeKfRecast(s, id, 0),
|
||||
(s) => removeKfAcorn(s, id, 0),
|
||||
);
|
||||
});
|
||||
|
||||
it("collapses to flat preserving the remaining keyframe's props (remove 100%)", () => {
|
||||
const id = idAt(SYN_KF2, 0);
|
||||
runParity(
|
||||
SYN_KF2,
|
||||
(s) => removeKfRecast(s, id, 100),
|
||||
(s) => removeKfAcorn(s, id, 100),
|
||||
);
|
||||
});
|
||||
|
||||
it("collapses a keyframes-with-easeEach fixture (drops easeEach + per-kf ease)", () => {
|
||||
// SYN_KF_EASE has 3 kfs; remove two so only one remains → collapse.
|
||||
const id1 = idAt(SYN_KF_EASE, 0);
|
||||
const afterFirst = runParity(
|
||||
SYN_KF_EASE,
|
||||
(s) => removeKfRecast(s, id1, 50),
|
||||
(s) => removeKfAcorn(s, id1, 50),
|
||||
);
|
||||
const id2 = idAt(afterFirst, 0);
|
||||
runParity(
|
||||
afterFirst,
|
||||
(s) => removeKfRecast(s, id2, 0),
|
||||
(s) => removeKfAcorn(s, id2, 0),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 6. Label correctness (acorn-only ops — no recast oracle to diff) ─────────────
|
||||
//
|
||||
// addLabelToScript / removeLabelFromScript exist only on the acorn writer, so we
|
||||
// cannot parity-test them. Instead we verify the source-level contract directly.
|
||||
|
||||
describe("correctness — addLabelToScript / removeLabelFromScript", () => {
|
||||
function labelCallCount(script: string, name: string): number {
|
||||
const re = new RegExp(`\\.addLabel\\(\\s*"${name}"`, "g");
|
||||
return (script.match(re) ?? []).length;
|
||||
}
|
||||
|
||||
it("adds an addLabel(name, pos) call after the last located tween", () => {
|
||||
const out = addLabelToScript(SYN_SINGLE, "mid", 1.5);
|
||||
expect(out).toContain('tl.addLabel("mid", 1.5);');
|
||||
// The label sits after the hero tween and before the postamble.
|
||||
expect(out.indexOf('addLabel("mid"')).toBeGreaterThan(out.indexOf('tl.to("#hero"'));
|
||||
expect(out.indexOf('addLabel("mid"')).toBeLessThan(out.indexOf("window.__timelines"));
|
||||
});
|
||||
|
||||
it("adds a label to an empty (tween-less) timeline after the declaration", () => {
|
||||
const empty = `var tl = gsap.timeline({ paused: true });\nwindow.__timelines["t"] = tl;`;
|
||||
const out = addLabelToScript(empty, "start", 0.8);
|
||||
expect(out).toContain('tl.addLabel("start", 0.8);');
|
||||
expect(out.indexOf('addLabel("start"')).toBeGreaterThan(out.indexOf("gsap.timeline"));
|
||||
});
|
||||
|
||||
it("removes a previously-added label (round-trip back to original)", () => {
|
||||
const empty = `var tl = gsap.timeline({ paused: true });\nwindow.__timelines["t"] = tl;`;
|
||||
const added = addLabelToScript(empty, "start", 0.8);
|
||||
const removed = removeLabelFromScript(added, "start");
|
||||
expect(removed).toBe(empty);
|
||||
});
|
||||
|
||||
it("removes a hand-authored label, leaving the tweens intact", () => {
|
||||
const out = removeLabelFromScript(SYN_LABELED, "intro");
|
||||
expect(labelCallCount(out, "intro")).toBe(0);
|
||||
expect(out).toContain('tl.to("#a"');
|
||||
expect(out).toContain('tl.to("#b"');
|
||||
});
|
||||
|
||||
it("is idempotent: removing an absent label is a no-op", () => {
|
||||
expect(removeLabelFromScript(SYN_SINGLE, "nope")).toBe(SYN_SINGLE);
|
||||
});
|
||||
|
||||
it("adding the same label twice MOVES it instead of duplicating (dedup contract)", () => {
|
||||
// A second addLabel for an existing name must not append a duplicate —
|
||||
// duplicates make removeLabel over-remove. It moves the label's position.
|
||||
const once = addLabelToScript(SYN_SINGLE, "mid", 1.0);
|
||||
const twice = addLabelToScript(once, "mid", 2.0);
|
||||
expect(labelCallCount(twice, "mid")).toBe(1);
|
||||
expect(twice).toContain('tl.addLabel("mid", 2)');
|
||||
});
|
||||
|
||||
it("removeLabel deletes ALL matching addLabel calls for the name (hand-authored dups)", () => {
|
||||
const dup = `var tl = gsap.timeline({ paused: true });\ntl.addLabel("mid", 1);\ntl.addLabel("mid", 2);\nwindow.__timelines["t"] = tl;`;
|
||||
expect(labelCallCount(dup, "mid")).toBe(2);
|
||||
expect(labelCallCount(removeLabelFromScript(dup, "mid"), "mid")).toBe(0);
|
||||
});
|
||||
|
||||
it("the added label is observable by the parser when a tween references it", () => {
|
||||
// Tween at numeric 0; add a label at 1.0; a follow-up tween positioned at the
|
||||
// label parses without error and is located alongside the others.
|
||||
const withLabel = addLabelToScript(SYN_SINGLE, "beat", 1.0);
|
||||
const parsed = parseGsapScriptAcorn(withLabel);
|
||||
expect(parsed.animations.length).toBe(1);
|
||||
expect(withLabel).toContain('tl.addLabel("beat", 1);');
|
||||
});
|
||||
|
||||
it("returns the script unchanged when there is no timeline to anchor to", () => {
|
||||
const noTl = `console.log("no timeline here");`;
|
||||
expect(addLabelToScript(noTl, "x", 1)).toBe(noTl);
|
||||
expect(removeLabelFromScript(noTl, "x")).toBe(noTl);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { ensureHfIds, mintHfId } from "./hfIds.js";
|
||||
import { parseHTML } from "linkedom";
|
||||
|
||||
function ids(html: string): string[] {
|
||||
const { document } = parseHTML(html);
|
||||
return Array.from(document.querySelectorAll("[data-hf-id]")).map(
|
||||
(e) => e.getAttribute("data-hf-id") as string,
|
||||
);
|
||||
}
|
||||
|
||||
// data-hf-id of the first element matching `selector`.
|
||||
function idOf(html: string, selector: string): string | null {
|
||||
const { document } = parseHTML(html);
|
||||
return document.querySelector(selector)?.getAttribute("data-hf-id") ?? null;
|
||||
}
|
||||
|
||||
const doc = (body: string) => `<!doctype html><html><body>${body}</body></html>`;
|
||||
|
||||
describe("ensureHfIds", () => {
|
||||
it("mints a hf- id on every editable element node in body", () => {
|
||||
const html = `<!doctype html><html><body>
|
||||
<div class="card"><h1>Hi</h1><img src="a.png"><span>x</span></div>
|
||||
</body></html>`;
|
||||
const out = ensureHfIds(html);
|
||||
for (const id of ids(out)) expect(id).toMatch(/^hf-[a-z0-9]{4}$/);
|
||||
// div, h1, img, span = 4 ids
|
||||
expect(ids(out)).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("skips script/style/template/meta and head", () => {
|
||||
const html = `<!doctype html><html><head><meta charset="utf-8"></head>
|
||||
<body><script>1</script><style>.a{}</style><p>keep</p></body></html>`;
|
||||
const out = ensureHfIds(html);
|
||||
// only the <p> gets an id
|
||||
expect(ids(out)).toHaveLength(1);
|
||||
expect(out).not.toContain("<script data-hf-id");
|
||||
expect(out).not.toContain("<style data-hf-id");
|
||||
expect(out).not.toContain("<meta data-hf-id");
|
||||
});
|
||||
|
||||
it("is idempotent: a second call mints nothing and is byte-stable", () => {
|
||||
const html = `<!doctype html><html><body><div><p>a</p></div></body></html>`;
|
||||
const once = ensureHfIds(html);
|
||||
const twice = ensureHfIds(once);
|
||||
expect(twice).toBe(once);
|
||||
});
|
||||
|
||||
it("pins existing data-hf-id and mints around it", () => {
|
||||
const html = `<!doctype html><html><body>
|
||||
<div data-hf-id="hf-keep"><p>a</p></div></body></html>`;
|
||||
const out = ensureHfIds(html);
|
||||
expect(out).toContain('data-hf-id="hf-keep"');
|
||||
expect(ids(out)).toContain("hf-keep");
|
||||
expect(ids(out)).toHaveLength(2); // div pinned + p minted
|
||||
});
|
||||
|
||||
it("two identical sibling nodes get distinct ids", () => {
|
||||
const html = `<!doctype html><html><body>
|
||||
<p class="x">same</p><p class="x">same</p></body></html>`;
|
||||
const got = ids(ensureHfIds(html));
|
||||
expect(new Set(got).size).toBe(got.length);
|
||||
});
|
||||
|
||||
it("is deterministic: same input → same ids", () => {
|
||||
const html = `<!doctype html><html><body><div><p>a</p><span>b</span></div></body></html>`;
|
||||
expect(ids(ensureHfIds(html))).toEqual(ids(ensureHfIds(html)));
|
||||
});
|
||||
|
||||
it("mintHfId rehashes on collision against the assigned set", () => {
|
||||
const { document } = parseHTML(`<p class="x">same</p>`);
|
||||
const el = document.querySelector("p") as Element;
|
||||
const assigned = new Set<string>();
|
||||
const a = mintHfId(el, assigned);
|
||||
const b = mintHfId(el, assigned); // identical element, same assigned set
|
||||
expect(a).not.toBe(b);
|
||||
expect(a).toMatch(/^hf-[a-z0-9]{4}$/);
|
||||
expect(b).toMatch(/^hf-[a-z0-9]{4}$/);
|
||||
});
|
||||
|
||||
// Post-persist stability: once data-hf-id is written back to source, edits
|
||||
// don't drift the id because the attribute is already present and pinned.
|
||||
it("pinned id survives text edit after first persist", () => {
|
||||
const raw = `<!doctype html><html><body><div>original text</div></body></html>`;
|
||||
const persisted = ensureHfIds(raw); // simulates write-back on first serve
|
||||
const [originalId] = ids(persisted);
|
||||
const edited = persisted.replace("original text", "edited text");
|
||||
expect(ids(ensureHfIds(edited))).toContain(originalId);
|
||||
});
|
||||
|
||||
// Hash-based stability (no prior pin): the same element content yields the
|
||||
// same id regardless of what sibling elements appear in the document.
|
||||
it("content-keyed minting is stable: same element content → same id in different documents", () => {
|
||||
const alone = `<!doctype html><html><body><div class="card">hello</div></body></html>`;
|
||||
const [idAlone] = ids(ensureHfIds(alone));
|
||||
// The <div class="card">hello</div> appears alongside a new sibling here.
|
||||
const withSibling = `<!doctype html><html><body><span>prefix</span><div class="card">hello</div></body></html>`;
|
||||
expect(ids(ensureHfIds(withSibling))).toContain(idAlone);
|
||||
});
|
||||
});
|
||||
|
||||
// Lock the edit-lifecycle behavior. These pin BOTH the guarantee that holds
|
||||
// once ids are persisted to source (pinning) AND the behavior for truly unpinned
|
||||
// HTML (no data-hf-id in the input — unreachable in production after write-back
|
||||
// landed in R7 Task 1-2, but still the correct contract for that path).
|
||||
describe("ensureHfIds — template-inner minting", () => {
|
||||
// linkedom's querySelectorAll does not descend into <template>, so extract
|
||||
// ids by regex over the serialized output instead of the DOM-walk helper.
|
||||
const rawIds = (html: string) => [...html.matchAll(/data-hf-id="([^"]+)"/g)].map((m) => m[1]);
|
||||
|
||||
it("mints ids on elements inside a <template> (template itself stays unstamped)", () => {
|
||||
const html = doc(
|
||||
`<template data-composition-id="t"><div class="clip" data-start="0" data-end="3">Hi</div><p>x</p></template>`,
|
||||
);
|
||||
const out = ensureHfIds(html);
|
||||
expect(rawIds(out)).toHaveLength(2); // div + p, not the template
|
||||
expect(out).not.toMatch(/<template[^>]*data-hf-id/);
|
||||
});
|
||||
|
||||
it("template-inner ids equal the ids minted for the same content unwrapped (preview parity)", () => {
|
||||
const inner = `<div class="clip" data-start="0" data-end="3">Hi</div><p>x</p>`;
|
||||
const wrapped = ensureHfIds(doc(`<template data-composition-id="t">${inner}</template>`));
|
||||
const unwrapped = ensureHfIds(doc(inner));
|
||||
expect(rawIds(wrapped)).toEqual(rawIds(unwrapped));
|
||||
});
|
||||
|
||||
it("pins existing template-inner ids and seeds them against fresh mints", () => {
|
||||
const html = doc(
|
||||
`<template data-composition-id="t"><div data-hf-id="hf-keep">a</div><p>b</p></template>`,
|
||||
);
|
||||
const out = ensureHfIds(html);
|
||||
expect(out).toContain('data-hf-id="hf-keep"');
|
||||
expect(new Set(rawIds(out)).size).toBe(2);
|
||||
});
|
||||
|
||||
it("descends nested composition templates", () => {
|
||||
const html = doc(
|
||||
`<template data-composition-id="a"><div>a</div><template data-composition-id="b"><span>b</span></template></template>`,
|
||||
);
|
||||
const out = ensureHfIds(html);
|
||||
expect(rawIds(out)).toHaveLength(2); // div + span
|
||||
});
|
||||
|
||||
it("does NOT stamp inside a plain <template> (runtime clone-source)", () => {
|
||||
// A plain template's content is cloned N times into the live DOM at
|
||||
// runtime; a persisted inner id would be duplicated across every clone.
|
||||
const html = doc(`<div class="stage">x</div><template><li class="row">item</li></template>`);
|
||||
const out = ensureHfIds(html);
|
||||
expect(rawIds(out)).toHaveLength(1); // only the stage div
|
||||
expect(out).not.toMatch(/<li[^>]*data-hf-id/);
|
||||
});
|
||||
|
||||
it("does NOT stamp inside a plain template nested in a composition template", () => {
|
||||
const html = doc(
|
||||
`<template data-composition-id="t"><div>a</div><template><li>clone-src</li></template></template>`,
|
||||
);
|
||||
const out = ensureHfIds(html);
|
||||
expect(rawIds(out)).toHaveLength(1); // only the div
|
||||
expect(out).not.toMatch(/<li[^>]*data-hf-id/);
|
||||
});
|
||||
|
||||
it("is idempotent for template-inner ids", () => {
|
||||
const once = ensureHfIds(doc(`<template data-composition-id="t"><div>a</div></template>`));
|
||||
expect(ensureHfIds(once)).toBe(once);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureHfIds — edit lifecycle (R1 stability)", () => {
|
||||
it("pinned id survives a content edit (the §3 write-back guarantee)", () => {
|
||||
// Element already carries data-hf-id in source (as it would after write-back).
|
||||
const edited = doc(`<p class="body" data-hf-id="hf-abcd">Hello world</p>`);
|
||||
expect(idOf(ensureHfIds(edited), "p.body")).toBe("hf-abcd");
|
||||
});
|
||||
|
||||
it("unpinned id drifts when element text is edited (pure-hash, unreachable after write-back)", () => {
|
||||
// No data-hf-id in source → every parse re-mints from content. This path is
|
||||
// unreachable in production after R7 write-back: the first serve pins the id.
|
||||
const before = idOf(ensureHfIds(doc(`<p class="body">Hello</p>`)), "p.body");
|
||||
const after = idOf(ensureHfIds(doc(`<p class="body">Hello world</p>`)), "p.body");
|
||||
expect(before).not.toBe(after);
|
||||
});
|
||||
|
||||
it("unpinned id drifts when attribute is edited (pure-hash, unreachable after write-back)", () => {
|
||||
const before = idOf(ensureHfIds(doc(`<p class="body">x</p>`)), "p");
|
||||
const after = idOf(ensureHfIds(doc(`<p class="lead">x</p>`)), "p");
|
||||
expect(before).not.toBe(after);
|
||||
});
|
||||
|
||||
it("identical-content siblings: second occurrence gets a position-derived dedup id", () => {
|
||||
// Insertion stability holds for DISTINCT content (covered elsewhere), but a
|
||||
// second identical sibling collides and gets a position-derived dedup id.
|
||||
// First element keeps the base (content-derived) id; documented in project_hfid_dedup_tiebreak.
|
||||
const single = idOf(ensureHfIds(doc(`<p class="x">same</p>`)), "p.x");
|
||||
const pair = ids(ensureHfIds(doc(`<p class="x">same</p><p class="x">same</p>`)));
|
||||
expect(pair[0]).toBe(single); // first identical element: stable, content-derived
|
||||
expect(pair[1]).not.toBe(single); // second: dedup id, exists only by position
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* 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 `<span></span>`
|
||||
* 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>): 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* True for a sub-composition authoring template whose content the studio preview
|
||||
* unwraps into the served body. Two accepted forms:
|
||||
* A) `<template data-composition-id="X">…` — the id on the template itself.
|
||||
* B) `<template id="X-template"><div data-composition-id="X">…` — the id on the
|
||||
* wrapped root div (the form `hyperframes add` scaffolds and registry blocks use).
|
||||
* Only these are treated as transparent containers for hf-id purposes. A plain
|
||||
* `<template>` (runtime clone-source: list item, particle, etc.) must NOT get
|
||||
* inner ids — its content is cloned N times into the live DOM, so a persisted
|
||||
* inner id would be duplicated across every clone. Form B is distinguished from
|
||||
* a clone-source by the presence of a direct `[data-composition-id]` child.
|
||||
*/
|
||||
function getChildElements(parent: Element): Element[] {
|
||||
const directChildren = Array.from(parent.children);
|
||||
if (directChildren.length || parent.tagName.toLowerCase() !== "template") return directChildren;
|
||||
const content = (parent as HTMLTemplateElement).content;
|
||||
if (content?.children.length) return Array.from(content.children);
|
||||
return directChildren;
|
||||
}
|
||||
|
||||
export function isCompositionTemplate(el: Element): boolean {
|
||||
if (el.tagName.toLowerCase() !== "template") return false;
|
||||
if (el.getAttribute("data-composition-id") !== null) return true;
|
||||
for (const child of getChildElements(el)) {
|
||||
if (child.getAttribute("data-composition-id") !== null) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk document-order descendants, descending through composition templates
|
||||
* while keeping plain templates inert. linkedom's querySelectorAll does not
|
||||
* expose template contents, so callers that model the served composition use
|
||||
* this traversal instead.
|
||||
*/
|
||||
export function walkCompositionDescendants(
|
||||
root: Document | Element,
|
||||
visit: (el: Element) => void,
|
||||
): void {
|
||||
const rootElement: Element | null =
|
||||
root.nodeType === 9 ? (root as Document).documentElement : (root as Element);
|
||||
if (!rootElement) return;
|
||||
|
||||
const walk = (parent: Element): void => {
|
||||
for (const child of getChildElements(parent)) {
|
||||
const isTemplate = child.tagName.toLowerCase() === "template";
|
||||
if (isTemplate && !isCompositionTemplate(child)) continue;
|
||||
visit(child);
|
||||
walk(child);
|
||||
}
|
||||
};
|
||||
|
||||
walk(rootElement);
|
||||
}
|
||||
|
||||
/**
|
||||
* Document-order walk of every element under `root`, descending into
|
||||
* composition `<template>` subtrees — linkedom's querySelectorAll does not, so
|
||||
* template-based sub-comps would otherwise never get inner ids (the preview
|
||||
* unwraps the template and stamps the SAME content, so skipping here splits
|
||||
* the id space between the served DOM and the raw file). Plain templates are
|
||||
* skipped entirely (see isCompositionTemplate).
|
||||
*/
|
||||
function walkElements(root: Element, visit: (el: Element) => void): void {
|
||||
walkCompositionDescendants(root, visit);
|
||||
}
|
||||
|
||||
export function ensureHfIds(html: string): string {
|
||||
// Mirror parseSourceDocument's fragment-wrapping so bare fragments don't land
|
||||
// outside <body> in linkedom, which would cause body.querySelectorAll to return [].
|
||||
const hasDocumentShell = /<!doctype|<html[\s>]/i.test(html);
|
||||
const wrapped = !hasDocumentShell;
|
||||
const { document } = wrapped
|
||||
? parseHTML(`<!DOCTYPE html><html><head></head><body>${html}</body></html>`)
|
||||
: parseHTML(html);
|
||||
const body = document.body;
|
||||
if (!body) return html;
|
||||
|
||||
const assigned = new Set<string>();
|
||||
// Seed with already-present ids (pin) so fresh mints never collide with them.
|
||||
// Scope to <body> to match the mint walk below — a stray data-hf-id in <head>
|
||||
// must not pin an id into the set that a body element would then be bumped off.
|
||||
walkElements(body, (el) => {
|
||||
const existing = el.getAttribute("data-hf-id");
|
||||
if (existing) assigned.add(existing);
|
||||
});
|
||||
|
||||
walkElements(body, (el) => {
|
||||
if (EXCLUDED_TAGS.has(el.tagName.toLowerCase())) return;
|
||||
if (el.getAttribute("data-hf-id")) return; // pinned
|
||||
el.setAttribute("data-hf-id", mintHfId(el, assigned));
|
||||
});
|
||||
|
||||
return wrapped ? document.body.innerHTML || "" : document.toString();
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*
|
||||
* htmlParser.ts's null-`documentElement` guards, exercised against
|
||||
* **linkedom's** `DOMParser` — the implementation the CLI actually polyfills
|
||||
* onto `globalThis` in production (see `packages/cli/src/utils/dom.ts`,
|
||||
* `ensureDOMParser`). The rest of htmlParser.test.ts runs under
|
||||
* `@vitest-environment jsdom`, whose spec-compliant `DOMParser` always
|
||||
* synthesizes a full `<html><head><body>` document — even for `""` or
|
||||
* non-HTML text — so `documentElement` is never null there and the guards
|
||||
* added in this file can't be exercised under jsdom at all. linkedom
|
||||
* deviates from spec on exactly this point (confirmed directly against the
|
||||
* installed package): `parseFromString("", ...)` and
|
||||
* `parseFromString("just some text", ...)` both return a document with
|
||||
* `documentElement === null`. That's the actual, live crash path in the CLI
|
||||
* (`hyperframes info`, `hyperframes inspect`, Studio's edit endpoints, etc.)
|
||||
* this test suite reproduces and guards.
|
||||
*/
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { DOMParser as LinkedomDOMParser } from "linkedom";
|
||||
import {
|
||||
CompositionHtmlParseError,
|
||||
parseHtml,
|
||||
updateElementInHtml,
|
||||
addElementToHtml,
|
||||
removeElementFromHtml,
|
||||
validateCompositionHtml,
|
||||
extractCompositionMetadata,
|
||||
} from "./htmlParser.js";
|
||||
|
||||
const originalDOMParser = (globalThis as Record<string, unknown>).DOMParser;
|
||||
|
||||
beforeAll(() => {
|
||||
(globalThis as Record<string, unknown>).DOMParser = LinkedomDOMParser;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
(globalThis as Record<string, unknown>).DOMParser = originalDOMParser;
|
||||
});
|
||||
|
||||
const EMPTY_INPUTS = ["", " \n\t ", "just some plain text, no tags at all"];
|
||||
|
||||
describe("htmlParser.ts null-documentElement guards (linkedom, matches CLI runtime)", () => {
|
||||
it.each(EMPTY_INPUTS)("parseHtml throws CompositionHtmlParseError for %j", (html) => {
|
||||
expect(() => parseHtml(html)).toThrow(CompositionHtmlParseError);
|
||||
expect(() => parseHtml(html)).toThrow(/empty or could not be parsed/);
|
||||
});
|
||||
|
||||
it.each(EMPTY_INPUTS)(
|
||||
"updateElementInHtml returns the input unchanged when the target id isn't found (never reaches documentElement)",
|
||||
(html) => {
|
||||
// getElementById/queryByAttr both miss on a documentElement-less doc, so
|
||||
// this hits the existing `if (!el) return html;` early return before
|
||||
// ever touching documentElement — same safe behavior as "id not found"
|
||||
// on a normal document. No guard needed on this path; asserted here so
|
||||
// a future refactor that removes the early return doesn't regress into
|
||||
// the null-deref crash.
|
||||
expect(updateElementInHtml(html, "some-id", { name: "x" })).toBe(html);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(EMPTY_INPUTS)("addElementToHtml throws CompositionHtmlParseError for %j", (html) => {
|
||||
expect(() =>
|
||||
addElementToHtml(html, {
|
||||
type: "text",
|
||||
name: "Title",
|
||||
startTime: 0,
|
||||
duration: 5,
|
||||
zIndex: 0,
|
||||
} as never),
|
||||
).toThrow(CompositionHtmlParseError);
|
||||
});
|
||||
|
||||
it.each(EMPTY_INPUTS)("removeElementFromHtml throws CompositionHtmlParseError for %j", (html) => {
|
||||
expect(() => removeElementFromHtml(html, "some-id")).toThrow(CompositionHtmlParseError);
|
||||
});
|
||||
|
||||
it.each(EMPTY_INPUTS)(
|
||||
"extractCompositionMetadata throws CompositionHtmlParseError for %j",
|
||||
(html) => {
|
||||
expect(() => extractCompositionMetadata(html)).toThrow(CompositionHtmlParseError);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(EMPTY_INPUTS)(
|
||||
"validateCompositionHtml returns a typed failure (not a throw) for %j",
|
||||
(html) => {
|
||||
const result = validateCompositionHtml(html);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors[0]).toMatch(/empty or could not be parsed/);
|
||||
},
|
||||
);
|
||||
|
||||
it("happy path: parseHtml succeeds against linkedom for well-formed HTML", () => {
|
||||
const html = `<!doctype html><html><body>
|
||||
<div id="stage">
|
||||
<div id="text1" data-start="0" data-end="5" data-name="Title"><div>Hello</div></div>
|
||||
</div>
|
||||
</body></html>`;
|
||||
const result = parseHtml(html);
|
||||
expect(result.elements).toHaveLength(1);
|
||||
expect(result.elements[0]?.name).toBe("Title");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*
|
||||
* T1 — parse→serialize round-trip (DOM/timing model only).
|
||||
* Scope: GSAP script fidelity is T6 territory; these tests cover element structure and timing.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
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 "@hyperframes/core/generators";
|
||||
|
||||
describe("T1 — parse→serialize round-trip (DOM/timing)", () => {
|
||||
it("preserves element count and ids through one round-trip", () => {
|
||||
const html = `
|
||||
<html><body><div id="stage">
|
||||
<div id="el-aaa" data-start="0" data-end="5" data-name="Title"><div>Hello</div></div>
|
||||
<div id="el-bbb" data-start="3" data-end="8" data-name="Body"><div>World</div></div>
|
||||
</div></body></html>
|
||||
`;
|
||||
const parsed = parseHtml(html);
|
||||
const reparsed = parseHtml(serialize(parsed));
|
||||
|
||||
expect(reparsed.elements).toHaveLength(parsed.elements.length);
|
||||
expect(reparsed.elements.map((e) => e.id)).toEqual(parsed.elements.map((e) => e.id));
|
||||
});
|
||||
|
||||
it("preserves startTime and duration through one round-trip", () => {
|
||||
const html = `
|
||||
<html><body><div id="stage">
|
||||
<video id="el-vid" data-start="1" data-end="6" src="video.mp4" data-name="Clip"></video>
|
||||
<div id="el-txt" data-start="0" data-end="3" data-name="Label"><div>Foo</div></div>
|
||||
</div></body></html>
|
||||
`;
|
||||
const parsed = parseHtml(html);
|
||||
const reparsed = parseHtml(serialize(parsed));
|
||||
|
||||
for (const orig of parsed.elements) {
|
||||
const round = reparsed.elements.find((e) => e.id === orig.id);
|
||||
expect(round).toBeDefined();
|
||||
expect(round?.startTime).toBe(orig.startTime);
|
||||
expect(round?.duration).toBe(orig.duration);
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves element types through one round-trip", () => {
|
||||
const html = `
|
||||
<html><body><div id="stage">
|
||||
<div id="el-text" data-start="0" data-end="4" data-name="T"><div>Hi</div></div>
|
||||
<video id="el-video" data-start="0" data-end="4" src="v.mp4" data-name="V"></video>
|
||||
<img id="el-img" data-start="0" data-end="4" src="i.jpg" data-name="I" />
|
||||
<audio id="el-aud" data-start="0" data-end="4" src="a.mp3" data-name="A"></audio>
|
||||
</div></body></html>
|
||||
`;
|
||||
const parsed = parseHtml(html);
|
||||
const reparsed = parseHtml(serialize(parsed));
|
||||
|
||||
for (const orig of parsed.elements) {
|
||||
const round = reparsed.elements.find((e) => e.id === orig.id);
|
||||
expect(round).toBeDefined();
|
||||
expect(round?.type).toBe(orig.type);
|
||||
}
|
||||
});
|
||||
|
||||
it("is stable — serialize(parse(serialize(parse(html)))) equals serialize(parse(html))", () => {
|
||||
const html = `
|
||||
<html><body><div id="stage">
|
||||
<img id="el-img" data-start="2" data-end="9" src="photo.jpg" data-name="Photo" />
|
||||
<audio id="el-aud" data-start="0" data-end="12" src="music.mp3" data-name="Music"></audio>
|
||||
</div></body></html>
|
||||
`;
|
||||
const parsed = parseHtml(html);
|
||||
const once = serialize(parsed);
|
||||
const twice = serialize(parseHtml(once));
|
||||
expect(twice).toBe(once);
|
||||
});
|
||||
|
||||
it("handles an empty stage without throwing", () => {
|
||||
const html = `<html><body><div id="stage"></div></body></html>`;
|
||||
const parsed = parseHtml(html);
|
||||
expect(() => serialize(parsed)).not.toThrow();
|
||||
const reparsed = parseHtml(serialize(parsed));
|
||||
expect(reparsed.elements).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("T1 — registry block round-trips (DOM/timing)", () => {
|
||||
const BLOCKS_DIR = join(dirname(fileURLToPath(import.meta.url)), "../../../../registry/blocks");
|
||||
|
||||
const blockNames = existsSync(BLOCKS_DIR)
|
||||
? readdirSync(BLOCKS_DIR, { withFileTypes: true })
|
||||
.filter((d) => d.isDirectory())
|
||||
.map((d) => d.name)
|
||||
: [];
|
||||
|
||||
for (const name of blockNames) {
|
||||
it(`${name}: round-trip preserves element count`, () => {
|
||||
const blockFile = join(BLOCKS_DIR, name, `${name}.html`);
|
||||
expect(existsSync(blockFile)).toBe(true);
|
||||
const html = readFileSync(blockFile, "utf8");
|
||||
const parsed = parseHtml(html);
|
||||
const reparsed = parseHtml(serialize(parsed));
|
||||
expect(reparsed.elements).toHaveLength(parsed.elements.length);
|
||||
});
|
||||
}
|
||||
|
||||
// GSAP smoke: verify the includeScripts path survives parse→generate on a block known
|
||||
// to contain <script> tags. Verbatim script fidelity is T6 territory; this catches R1
|
||||
// accidentally breaking the script-generation path entirely.
|
||||
it("generates scripts when includeScripts is true (GSAP smoke)", () => {
|
||||
const name = "liquid-glass-notification";
|
||||
const blockFile = join(BLOCKS_DIR, name, `${name}.html`);
|
||||
if (!existsSync(blockFile)) return;
|
||||
const html = readFileSync(blockFile, "utf8");
|
||||
expect(html).toMatch(/<script/);
|
||||
const parsed = parseHtml(html);
|
||||
const out = generateHyperframesHtml(parsed.elements, maxEndTime(parsed.elements), {
|
||||
compositionId: "test-comp",
|
||||
resolution: parsed.resolution,
|
||||
styles: parsed.styles ?? undefined,
|
||||
keyframes: parsed.keyframes,
|
||||
stageZoomKeyframes: parsed.stageZoomKeyframes,
|
||||
includeScripts: true,
|
||||
});
|
||||
expect(out).toMatch(/<script/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,822 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
parseHtml,
|
||||
updateElementInHtml,
|
||||
addElementToHtml,
|
||||
removeElementFromHtml,
|
||||
validateCompositionHtml,
|
||||
extractCompositionMetadata,
|
||||
} from "./htmlParser.js";
|
||||
|
||||
describe("parseHtml", () => {
|
||||
it("extracts elements with data-start and data-end", () => {
|
||||
const html = `
|
||||
<html>
|
||||
<body>
|
||||
<div id="stage">
|
||||
<div id="text1" data-start="0" data-end="5" data-name="Title"><div>Hello World</div></div>
|
||||
<div id="text2" data-start="2" data-end="7" data-name="Subtitle"><div>Sub</div></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
const result = parseHtml(html);
|
||||
|
||||
expect(result.elements).toHaveLength(2);
|
||||
expect(result.elements[0].id).toMatch(/^hf-[a-z0-9]{4}$/);
|
||||
expect(result.elements[0].startTime).toBe(0);
|
||||
expect(result.elements[0].duration).toBe(5);
|
||||
expect(result.elements[0].name).toBe("Title");
|
||||
expect(result.elements[0].type).toBe("text");
|
||||
|
||||
expect(result.elements[1].id).toMatch(/^hf-[a-z0-9]{4}$/);
|
||||
expect(result.elements[1].startTime).toBe(2);
|
||||
expect(result.elements[1].duration).toBe(5);
|
||||
});
|
||||
|
||||
it("handles nested compositions", () => {
|
||||
const html = `
|
||||
<html>
|
||||
<body>
|
||||
<div id="stage">
|
||||
<div id="comp1" data-start="0" data-end="10" data-type="composition" data-composition-id="abc123">
|
||||
<iframe src="/compositions/abc123"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
const result = parseHtml(html);
|
||||
|
||||
expect(result.elements).toHaveLength(1);
|
||||
expect(result.elements[0].type).toBe("composition");
|
||||
expect(result.elements[0].id).toMatch(/^hf-[a-z0-9]{4}$/);
|
||||
if (result.elements[0].type === "composition") {
|
||||
expect(result.elements[0].compositionId).toBe("abc123");
|
||||
expect(result.elements[0].src).toBe("/compositions/abc123");
|
||||
}
|
||||
});
|
||||
|
||||
it("extracts media elements (video, audio, img)", () => {
|
||||
const html = `
|
||||
<html>
|
||||
<body>
|
||||
<div id="stage">
|
||||
<video id="vid1" data-start="0" data-end="10" src="video.mp4" data-name="My Video"></video>
|
||||
<audio id="aud1" data-start="0" data-end="5" src="music.mp3" data-name="Music"></audio>
|
||||
<img id="img1" data-start="2" data-end="8" src="photo.jpg" data-name="Photo" />
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
const result = parseHtml(html);
|
||||
|
||||
expect(result.elements).toHaveLength(3);
|
||||
|
||||
const video = result.elements.find((e) => e.type === "video");
|
||||
expect(video).toBeDefined();
|
||||
expect(video?.id).toMatch(/^hf-[a-z0-9]{4}$/);
|
||||
if (video?.type === "video") {
|
||||
expect(video.src).toBe("video.mp4");
|
||||
}
|
||||
|
||||
const audio = result.elements.find((e) => e.type === "audio");
|
||||
expect(audio).toBeDefined();
|
||||
expect(audio?.id).toMatch(/^hf-[a-z0-9]{4}$/);
|
||||
|
||||
const img = result.elements.find((e) => e.type === "image");
|
||||
expect(img).toBeDefined();
|
||||
expect(img?.id).toMatch(/^hf-[a-z0-9]{4}$/);
|
||||
});
|
||||
|
||||
it("handles missing attributes gracefully", () => {
|
||||
const html = `
|
||||
<html>
|
||||
<body>
|
||||
<div id="stage">
|
||||
<div id="el1" data-start="3"><div>Some text</div></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
const result = parseHtml(html);
|
||||
|
||||
expect(result.elements).toHaveLength(1);
|
||||
expect(result.elements[0].startTime).toBe(3);
|
||||
// Default duration is 5 when data-end is missing
|
||||
expect(result.elements[0].duration).toBe(5);
|
||||
});
|
||||
|
||||
it("assigns generated ids when elements have no id", () => {
|
||||
const html = `
|
||||
<html>
|
||||
<body>
|
||||
<div id="stage">
|
||||
<div data-start="0" data-end="5"><div>No ID</div></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
const result = parseHtml(html);
|
||||
|
||||
expect(result.elements).toHaveLength(1);
|
||||
expect(result.elements[0].id).toMatch(/^hf-[a-z0-9]{4}$/);
|
||||
});
|
||||
|
||||
it("extracts GSAP script from script tags", () => {
|
||||
const html = `
|
||||
<html>
|
||||
<body>
|
||||
<div id="stage">
|
||||
<div id="text1" data-start="0" data-end="5"><div>Hello</div></div>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
|
||||
<script>
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#text1", { opacity: 1, duration: 1 }, 0);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
const result = parseHtml(html);
|
||||
|
||||
expect(result.gsapScript).not.toBeNull();
|
||||
expect(result.gsapScript).toContain("gsap.timeline");
|
||||
expect(result.gsapScript).toContain('tl.to("#text1"');
|
||||
});
|
||||
|
||||
it("extracts GSAP script from composition templates", () => {
|
||||
const html = `
|
||||
<html>
|
||||
<body>
|
||||
<div id="stage"></div>
|
||||
<template data-composition-id="sub-comp">
|
||||
<script>const tl = gsap.timeline({ paused: true });</script>
|
||||
</template>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
const result = parseHtml(html);
|
||||
|
||||
expect(result.gsapScript).toContain("gsap.timeline");
|
||||
});
|
||||
|
||||
it("extracts styles from style tags", () => {
|
||||
const html = `
|
||||
<html>
|
||||
<body>
|
||||
<style data-hf-custom="true">
|
||||
.my-class { color: red; }
|
||||
</style>
|
||||
<div id="stage">
|
||||
<div id="text1" data-start="0" data-end="5"><div>Hello</div></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
const result = parseHtml(html);
|
||||
|
||||
expect(result.styles).not.toBeNull();
|
||||
expect(result.styles).toContain(".my-class");
|
||||
});
|
||||
|
||||
it("detects landscape resolution from data attribute", () => {
|
||||
const html = `
|
||||
<html data-resolution="landscape">
|
||||
<body>
|
||||
<div id="stage">
|
||||
<div id="text1" data-start="0" data-end="5"><div>Hello</div></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
const result = parseHtml(html);
|
||||
|
||||
expect(result.resolution).toBe("landscape");
|
||||
});
|
||||
|
||||
it("detects portrait resolution from data attribute", () => {
|
||||
const html = `
|
||||
<html data-resolution="portrait">
|
||||
<body>
|
||||
<div id="stage">
|
||||
<div id="text1" data-start="0" data-end="5"><div>Hello</div></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
const result = parseHtml(html);
|
||||
|
||||
expect(result.resolution).toBe("portrait");
|
||||
});
|
||||
|
||||
it("keeps explicit portrait resolution even when dimensions are square", () => {
|
||||
const html = `
|
||||
<html data-resolution="portrait" data-composition-width="1080" data-composition-height="1080">
|
||||
<body>
|
||||
<div id="stage">
|
||||
<div id="text1" data-start="0" data-end="5"><div>Hello</div></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
const result = parseHtml(html);
|
||||
|
||||
expect(result.resolution).toBe("portrait");
|
||||
});
|
||||
|
||||
it("defaults to portrait when no resolution info is available", () => {
|
||||
const html = `
|
||||
<html>
|
||||
<body>
|
||||
<div id="stage">
|
||||
<div id="text1" data-start="0" data-end="5"><div>Hello</div></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
const result = parseHtml(html);
|
||||
|
||||
expect(result.resolution).toBe("portrait");
|
||||
});
|
||||
|
||||
it("detects landscape-4k resolution from data attribute", () => {
|
||||
const html = `
|
||||
<html data-resolution="landscape-4k">
|
||||
<body>
|
||||
<div id="stage">
|
||||
<div id="text1" data-start="0" data-end="5"><div>Hello</div></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
const result = parseHtml(html);
|
||||
|
||||
expect(result.resolution).toBe("landscape-4k");
|
||||
});
|
||||
|
||||
it("infers landscape-4k from composition dimensions", () => {
|
||||
const html = `
|
||||
<html data-composition-width="3840" data-composition-height="2160">
|
||||
<body>
|
||||
<div id="stage">
|
||||
<div id="text1" data-start="0" data-end="5"><div>Hello</div></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
const result = parseHtml(html);
|
||||
|
||||
expect(result.resolution).toBe("landscape-4k");
|
||||
});
|
||||
|
||||
it("infers portrait-4k from inline stage style", () => {
|
||||
const html = `
|
||||
<html>
|
||||
<body>
|
||||
<div id="stage" style="width: 2160px; height: 3840px;">
|
||||
<div id="text1" data-start="0" data-end="5"><div>Hello</div></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
const result = parseHtml(html);
|
||||
|
||||
expect(result.resolution).toBe("portrait-4k");
|
||||
});
|
||||
|
||||
it("classifies 1440p (QHD) as landscape, not landscape-4k", () => {
|
||||
// Regression: an earlier `>= 2560` cutoff misclassified QHD compositions
|
||||
// as 4K. The current rule uses the canonical 4K long-side (3840) so
|
||||
// 2560×1440 stays in the landscape preset.
|
||||
const html = `
|
||||
<html data-composition-width="2560" data-composition-height="1440">
|
||||
<body>
|
||||
<div id="stage">
|
||||
<div id="text1" data-start="0" data-end="5"><div>Hello</div></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
const result = parseHtml(html);
|
||||
|
||||
expect(result.resolution).toBe("landscape");
|
||||
});
|
||||
|
||||
it("infers square resolution from equal width/height", () => {
|
||||
const html = `
|
||||
<html data-composition-width="1080" data-composition-height="1080">
|
||||
<body>
|
||||
<div id="stage">
|
||||
<div id="text1" data-start="0" data-end="5"><div>Hello</div></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
const result = parseHtml(html);
|
||||
|
||||
expect(result.resolution).toBe("square");
|
||||
});
|
||||
|
||||
it("infers square-4k from equal width/height >= 2160", () => {
|
||||
const html = `
|
||||
<html data-composition-width="2160" data-composition-height="2160">
|
||||
<body>
|
||||
<div id="stage">
|
||||
<div id="text1" data-start="0" data-end="5"><div>Hello</div></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
const result = parseHtml(html);
|
||||
|
||||
expect(result.resolution).toBe("square-4k");
|
||||
});
|
||||
|
||||
it("extracts x, y, scale, opacity from data attributes", () => {
|
||||
const html = `
|
||||
<html>
|
||||
<body>
|
||||
<div id="stage">
|
||||
<video id="vid1" data-start="0" data-end="5" src="v.mp4" data-x="100" data-y="200" data-scale="1.5" data-opacity="0.8"></video>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
const result = parseHtml(html);
|
||||
|
||||
expect(result.elements[0].x).toBe(100);
|
||||
expect(result.elements[0].y).toBe(200);
|
||||
expect(result.elements[0].scale).toBe(1.5);
|
||||
expect(result.elements[0].opacity).toBe(0.8);
|
||||
});
|
||||
|
||||
it("parses text element properties (color, fontSize, fontWeight, fontFamily)", () => {
|
||||
const html = `
|
||||
<html>
|
||||
<body>
|
||||
<div id="stage">
|
||||
<div id="text1" data-start="0" data-end="5" data-color="red" data-font-size="72" data-font-weight="900" data-font-family="Montserrat"><div>Styled</div></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
const result = parseHtml(html);
|
||||
|
||||
const textEl = result.elements[0];
|
||||
expect(textEl.type).toBe("text");
|
||||
if (textEl.type === "text") {
|
||||
expect(textEl.color).toBe("red");
|
||||
expect(textEl.fontSize).toBe(72);
|
||||
expect(textEl.fontWeight).toBe(900);
|
||||
expect(textEl.fontFamily).toBe("Montserrat");
|
||||
}
|
||||
});
|
||||
|
||||
it("parses media element properties (mediaStartTime, sourceDuration, volume)", () => {
|
||||
const html = `
|
||||
<html>
|
||||
<body>
|
||||
<div id="stage">
|
||||
<video id="vid1" data-start="0" data-end="10" src="v.mp4" data-media-start="5" data-source-duration="30" data-volume="0.5" data-has-audio="true"></video>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
const result = parseHtml(html);
|
||||
|
||||
const vid = result.elements[0];
|
||||
expect(vid.type).toBe("video");
|
||||
if (vid.type === "video") {
|
||||
expect(vid.mediaStartTime).toBe(5);
|
||||
expect(vid.sourceDuration).toBe(30);
|
||||
expect(vid.volume).toBe(0.5);
|
||||
expect(vid.hasAudio).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("extracts data-keyframes attribute", () => {
|
||||
const keyframes = JSON.stringify([
|
||||
{ id: "kf1", time: 0, properties: { opacity: 0 } },
|
||||
{ id: "kf2", time: 1, properties: { opacity: 1 } },
|
||||
]);
|
||||
const html = `
|
||||
<html>
|
||||
<body>
|
||||
<div id="stage">
|
||||
<div id="text1" data-start="0" data-end="5" data-keyframes='${keyframes}'><div>Hello</div></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
const result = parseHtml(html);
|
||||
|
||||
const elId = result.elements[0]?.id ?? "";
|
||||
expect(elId).toMatch(/^hf-[a-z0-9]{4}$/);
|
||||
const kfs = result.keyframes[elId];
|
||||
expect(kfs).toBeDefined();
|
||||
expect(kfs).toHaveLength(2);
|
||||
expect(kfs[0].id).toBe("kf1");
|
||||
});
|
||||
|
||||
it("parses stage zoom keyframes", () => {
|
||||
const zoomKeyframes = JSON.stringify([
|
||||
{ id: "z1", time: 0, zoom: { scale: 1, focusX: 960, focusY: 540 } },
|
||||
{ id: "z2", time: 2, zoom: { scale: 2, focusX: 500, focusY: 300 } },
|
||||
]);
|
||||
const html = `
|
||||
<html>
|
||||
<body>
|
||||
<div id="stage">
|
||||
<div id="stage-zoom-container" data-zoom-keyframes='${zoomKeyframes}'></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
const result = parseHtml(html);
|
||||
|
||||
expect(result.stageZoomKeyframes).toHaveLength(2);
|
||||
expect(result.stageZoomKeyframes[0].zoom.scale).toBe(1);
|
||||
expect(result.stageZoomKeyframes[1].zoom.scale).toBe(2);
|
||||
});
|
||||
|
||||
it("returns empty zoom keyframes when no zoom container exists", () => {
|
||||
const html = `
|
||||
<html>
|
||||
<body><div id="stage"></div></body>
|
||||
</html>
|
||||
`;
|
||||
const result = parseHtml(html);
|
||||
expect(result.stageZoomKeyframes).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateElementInHtml", () => {
|
||||
it("updates startTime and duration", () => {
|
||||
const html = `<!DOCTYPE html>
|
||||
<html><body>
|
||||
<div id="el1" data-start="0" data-end="5"><div>Hello</div></div>
|
||||
</body></html>`;
|
||||
|
||||
const updated = updateElementInHtml(html, "el1", { startTime: 2, duration: 3 });
|
||||
|
||||
expect(updated).toContain('data-start="2"');
|
||||
expect(updated).toContain('data-end="5"'); // data-end gets set to start + duration
|
||||
});
|
||||
|
||||
it("updates element name", () => {
|
||||
const html = `<!DOCTYPE html>
|
||||
<html><body>
|
||||
<div id="el1" data-start="0" data-end="5" data-name="Old"><div>Hello</div></div>
|
||||
</body></html>`;
|
||||
|
||||
const updated = updateElementInHtml(html, "el1", { name: "New Name" });
|
||||
|
||||
expect(updated).toContain('data-name="New Name"');
|
||||
});
|
||||
|
||||
it("returns original html when element not found", () => {
|
||||
const html = `<!DOCTYPE html>
|
||||
<html><body>
|
||||
<div id="el1" data-start="0" data-end="5"><div>Hello</div></div>
|
||||
</body></html>`;
|
||||
|
||||
const updated = updateElementInHtml(html, "nonexistent", { name: "Test" });
|
||||
|
||||
expect(updated).toBe(html);
|
||||
});
|
||||
});
|
||||
|
||||
describe("addElementToHtml", () => {
|
||||
it("adds a new text element to the HTML", () => {
|
||||
const html = `<!DOCTYPE html>
|
||||
<html><body>
|
||||
<div id="stage">
|
||||
<div id="stage-zoom-container"></div>
|
||||
</div>
|
||||
</body></html>`;
|
||||
|
||||
const { html: updated, id } = addElementToHtml(html, {
|
||||
type: "text",
|
||||
name: "New Text",
|
||||
content: "Hello!",
|
||||
startTime: 1,
|
||||
duration: 3,
|
||||
zIndex: 1,
|
||||
});
|
||||
|
||||
expect(id).toBeDefined();
|
||||
expect(updated).toContain(`id="${id}"`);
|
||||
expect(updated).toContain('data-start="1"');
|
||||
expect(updated).toContain('data-end="4"');
|
||||
expect(updated).toContain("Hello!");
|
||||
});
|
||||
|
||||
it("adds a video element", () => {
|
||||
const html = `<!DOCTYPE html>
|
||||
<html><body>
|
||||
<div id="stage">
|
||||
<div id="stage-zoom-container"></div>
|
||||
</div>
|
||||
</body></html>`;
|
||||
|
||||
const { html: updated, id } = addElementToHtml(html, {
|
||||
type: "video",
|
||||
name: "My Video",
|
||||
src: "video.mp4",
|
||||
startTime: 0,
|
||||
duration: 10,
|
||||
zIndex: 0,
|
||||
});
|
||||
|
||||
expect(updated).toContain(`id="${id}"`);
|
||||
expect(updated).toContain("video.mp4");
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeElementFromHtml", () => {
|
||||
it("removes an element by id", () => {
|
||||
const html = `<!DOCTYPE html>
|
||||
<html><body>
|
||||
<div id="stage">
|
||||
<div id="el1" data-start="0" data-end="5"><div>Hello</div></div>
|
||||
<div id="el2" data-start="1" data-end="6"><div>World</div></div>
|
||||
</div>
|
||||
</body></html>`;
|
||||
|
||||
const updated = removeElementFromHtml(html, "el1");
|
||||
|
||||
expect(updated).not.toContain('id="el1"');
|
||||
expect(updated).toContain('id="el2"');
|
||||
});
|
||||
|
||||
it("strips ALL gsap tweens for the removed element, not just the first", () => {
|
||||
// Two tweens on the same element → count-based ids renumber when the first is
|
||||
// removed, so a single up-front parse left the second tween orphaned.
|
||||
const html = `<!DOCTYPE html>
|
||||
<html><body>
|
||||
<div id="stage">
|
||||
<div id="box" data-hf-id="box" data-start="0" data-end="5">box</div>
|
||||
</div>
|
||||
<script>
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.to("[data-hf-id=\\"box\\"]", { x: 100, duration: 1 }, 0);
|
||||
tl.to("[data-hf-id=\\"box\\"]", { x: 200, duration: 1 }, 1);
|
||||
</script>
|
||||
</body></html>`;
|
||||
|
||||
const updated = removeElementFromHtml(html, "box");
|
||||
|
||||
expect(updated).not.toContain('data-hf-id="box"');
|
||||
// Neither tween may survive — the orphaned second tl.to referenced a deleted element.
|
||||
expect(updated).not.toContain("x: 100");
|
||||
expect(updated).not.toContain("x: 200");
|
||||
});
|
||||
|
||||
it("strips GSAP tweens from composition templates", () => {
|
||||
const html = `<!DOCTYPE html>
|
||||
<html><body>
|
||||
<div id="stage">
|
||||
<div id="box" data-hf-id="box" data-start="0" data-end="5">box</div>
|
||||
</div>
|
||||
<template data-composition-id="sub-comp">
|
||||
<script>
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.to("[data-hf-id=\\"box\\"]", { x: 100, duration: 1 }, 0);
|
||||
</script>
|
||||
</template>
|
||||
</body></html>`;
|
||||
|
||||
const updated = removeElementFromHtml(html, "box");
|
||||
|
||||
expect(updated).not.toContain('data-hf-id="box"');
|
||||
expect(updated).not.toContain("x: 100");
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateCompositionHtml", () => {
|
||||
it("returns valid for a well-formed composition", () => {
|
||||
const html = `<!DOCTYPE html>
|
||||
<html data-composition-id="comp-1" data-composition-duration="10">
|
||||
<body>
|
||||
<div id="stage"></div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const result = validateCompositionHtml(html);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("reports error for missing composition-id", () => {
|
||||
const html = `<!DOCTYPE html>
|
||||
<html data-composition-duration="10">
|
||||
<body>
|
||||
<div id="stage"></div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const result = validateCompositionHtml(html);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain("Missing data-composition-id attribute on <html> element");
|
||||
});
|
||||
|
||||
it("reports error for missing composition-duration", () => {
|
||||
const html = `<!DOCTYPE html>
|
||||
<html data-composition-id="comp-1">
|
||||
<body>
|
||||
<div id="stage"></div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const result = validateCompositionHtml(html);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain(
|
||||
"Missing data-composition-duration attribute on <html> element",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports error for missing #stage", () => {
|
||||
const html = `<!DOCTYPE html>
|
||||
<html data-composition-id="comp-1" data-composition-duration="10">
|
||||
<body></body>
|
||||
</html>`;
|
||||
|
||||
const result = validateCompositionHtml(html);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain("Missing #stage element");
|
||||
});
|
||||
|
||||
it("reports error for inline event handlers", () => {
|
||||
const html = `<!DOCTYPE html>
|
||||
<html data-composition-id="comp-1" data-composition-duration="10">
|
||||
<body>
|
||||
<div id="stage" onclick="alert('hi')"></div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const result = validateCompositionHtml(html);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain("Inline event handlers (onclick, onload, etc.) not allowed");
|
||||
});
|
||||
|
||||
it("validates GSAP scripts inside composition templates", () => {
|
||||
const html = `<!DOCTYPE html>
|
||||
<html data-composition-id="comp-1" data-composition-duration="10">
|
||||
<body>
|
||||
<div id="stage"></div>
|
||||
<template data-composition-id="sub-comp">
|
||||
<script>
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#text1", { onComplete: () => {}, duration: 1 }, 0);
|
||||
</script>
|
||||
</template>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const result = validateCompositionHtml(html);
|
||||
|
||||
expect(result.errors).toContain("onComplete callback not allowed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractCompositionMetadata", () => {
|
||||
it("extracts composition id and duration", () => {
|
||||
const html = `<!DOCTYPE html>
|
||||
<html data-composition-id="comp-abc" data-composition-duration="15.5">
|
||||
<body></body>
|
||||
</html>`;
|
||||
|
||||
const meta = extractCompositionMetadata(html);
|
||||
expect(meta.compositionId).toBe("comp-abc");
|
||||
expect(meta.compositionDuration).toBe(15.5);
|
||||
});
|
||||
|
||||
it("returns null for missing metadata", () => {
|
||||
const html = `<!DOCTYPE html><html><body></body></html>`;
|
||||
|
||||
const meta = extractCompositionMetadata(html);
|
||||
expect(meta.compositionId).toBeNull();
|
||||
expect(meta.compositionDuration).toBeNull();
|
||||
});
|
||||
|
||||
it("extracts composition variables", () => {
|
||||
const variables = JSON.stringify([
|
||||
{ id: "title", type: "string", label: "Title", default: "Hello" },
|
||||
{ id: "count", type: "number", label: "Count", default: 5 },
|
||||
]);
|
||||
const html = `<!DOCTYPE html>
|
||||
<html data-composition-id="comp-1" data-composition-duration="10" data-composition-variables='${variables}'>
|
||||
<body></body>
|
||||
</html>`;
|
||||
|
||||
const meta = extractCompositionMetadata(html);
|
||||
expect(meta.variables).toHaveLength(2);
|
||||
expect(meta.variables[0].id).toBe("title");
|
||||
expect(meta.variables[0].type).toBe("string");
|
||||
expect(meta.variables[1].id).toBe("count");
|
||||
expect(meta.variables[1].type).toBe("number");
|
||||
});
|
||||
|
||||
// T9 — CompositionVariable font/image parse (WS-B R1 implemented).
|
||||
|
||||
it("parses a font variable (type: font) with name and source", () => {
|
||||
const variables = JSON.stringify([
|
||||
{
|
||||
id: "brand-font-primary",
|
||||
type: "font",
|
||||
label: "Heading font",
|
||||
default: "Inter",
|
||||
source: "https://fonts.googleapis.com/css2?family=Inter",
|
||||
default_name: "sans-serif",
|
||||
default_source: "",
|
||||
},
|
||||
]);
|
||||
const html = `<!DOCTYPE html>
|
||||
<html data-composition-id="c" data-composition-duration="5" data-composition-variables='${variables}'>
|
||||
<body></body></html>`;
|
||||
const meta = extractCompositionMetadata(html);
|
||||
const v = meta.variables.find((x) => x.id === "brand-font-primary");
|
||||
expect(v).toBeDefined();
|
||||
expect(v?.type).toBe("font");
|
||||
// source, default_name, default_source are load-bearing for brand-kit font apply.
|
||||
expect((v as Record<string, unknown>)?.source).toBe(
|
||||
"https://fonts.googleapis.com/css2?family=Inter",
|
||||
);
|
||||
expect((v as Record<string, unknown>)?.default_name).toBe("sans-serif");
|
||||
expect((v as Record<string, unknown>)?.default_source).toBe("");
|
||||
});
|
||||
|
||||
it("parses an image variable with brandRole logo:primary", () => {
|
||||
const variables = JSON.stringify([
|
||||
{ id: "brand-logo", type: "image", label: "Logo", default: "", brandRole: "logo:primary" },
|
||||
]);
|
||||
const html = `<!DOCTYPE html>
|
||||
<html data-composition-id="c" data-composition-duration="5" data-composition-variables='${variables}'>
|
||||
<body></body></html>`;
|
||||
const meta = extractCompositionMetadata(html);
|
||||
const v = meta.variables.find((x) => x.id === "brand-logo");
|
||||
expect(v).toBeDefined();
|
||||
expect(v?.type).toBe("image");
|
||||
expect((v as Record<string, unknown>)?.brandRole).toBe("logo:primary");
|
||||
});
|
||||
|
||||
it("[spec] parses a color variable with brandRole and preserves the brandRole field", () => {
|
||||
const variables = JSON.stringify([
|
||||
{
|
||||
id: "brand-color-primary",
|
||||
type: "color",
|
||||
label: "Primary",
|
||||
default: "#0066cc",
|
||||
brandRole: "color:primary",
|
||||
},
|
||||
]);
|
||||
const html = `<!DOCTYPE html>
|
||||
<html data-composition-id="c" data-composition-duration="5" data-composition-variables='${variables}'>
|
||||
<body></body></html>`;
|
||||
const meta = extractCompositionMetadata(html);
|
||||
const v = meta.variables.find((x) => x.id === "brand-color-primary");
|
||||
expect(v).toBeDefined();
|
||||
// TODO(R1): remove cast once ColorVariable.brandRole is typed
|
||||
expect((v as Record<string, unknown>)?.brandRole).toBe("color:primary");
|
||||
});
|
||||
|
||||
it("color variable without brandRole parses normally (brandRole is optional)", () => {
|
||||
const variables = JSON.stringify([
|
||||
{ id: "accent", type: "color", label: "Accent", default: "#ff0000" },
|
||||
]);
|
||||
const html = `<!DOCTYPE html>
|
||||
<html data-composition-id="c" data-composition-duration="5" data-composition-variables='${variables}'>
|
||||
<body></body></html>`;
|
||||
const meta = extractCompositionMetadata(html);
|
||||
const v = meta.variables.find((x) => x.id === "accent");
|
||||
expect(v).toBeDefined();
|
||||
expect(v?.type).toBe("color");
|
||||
expect((v as Record<string, unknown>)?.brandRole).toBeUndefined();
|
||||
});
|
||||
|
||||
it.todo(
|
||||
"[spec] font variable without source field is rejected (source is required for brand-kit font apply)",
|
||||
);
|
||||
|
||||
it.todo(
|
||||
"[spec] image variable without default is rejected gracefully (default is required for image variables)",
|
||||
);
|
||||
|
||||
it("unknown variable type is rejected gracefully (does not throw, variable is skipped)", () => {
|
||||
const variables = JSON.stringify([
|
||||
{ id: "ok", type: "string", label: "Ok", default: "yes" },
|
||||
{ id: "x", type: "widget", label: "Unknown", default: null },
|
||||
]);
|
||||
const html = `<!DOCTYPE html>
|
||||
<html data-composition-id="c" data-composition-duration="5" data-composition-variables='${variables}'>
|
||||
<body></body></html>`;
|
||||
expect(() => extractCompositionMetadata(html)).not.toThrow();
|
||||
const meta = extractCompositionMetadata(html);
|
||||
expect(meta.variables.find((v) => v.id === "x")).toBeUndefined();
|
||||
expect(meta.variables.find((v) => v.id === "ok")).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,885 @@
|
||||
import type {
|
||||
TimelineElement,
|
||||
TimelineElementType,
|
||||
TimelineMediaElement,
|
||||
TimelineTextElement,
|
||||
TimelineCompositionElement,
|
||||
CanvasResolution,
|
||||
Keyframe,
|
||||
KeyframeProperties,
|
||||
StageZoomKeyframe,
|
||||
CompositionVariable,
|
||||
ValidationResult,
|
||||
} from "./types.js";
|
||||
import { validateCompositionGsap } from "./gsapSerialize";
|
||||
import { parseCompositionVariables } from "./compositionVariables.js";
|
||||
import { ensureHfIds, walkCompositionDescendants } from "./hfIds.js";
|
||||
import { parseGsapScriptAcornForWrite } from "./gsapParserAcorn.js";
|
||||
import { queryByAttr } from "./utils/cssSelector.js";
|
||||
import { removeAnimationFromScript } from "./gsapWriterAcorn.js";
|
||||
|
||||
const MEDIA_TYPES = new Set<string>(["video", "image", "audio"]);
|
||||
|
||||
/**
|
||||
* Thrown by htmlParser functions when the input HTML is empty or does not
|
||||
* parse to a document with a `documentElement` — the condition that,
|
||||
* unguarded, previously surfaced as a raw
|
||||
* `Cannot read properties of null (reading '...')` /
|
||||
* `Cannot destructure property 'firstElementChild' of 'documentElement' as
|
||||
* it is null` crash deep inside the DOM implementation instead of a clear,
|
||||
* catchable error naming which function received bad input.
|
||||
*/
|
||||
export class CompositionHtmlParseError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "CompositionHtmlParseError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface ParsedHtml {
|
||||
elements: TimelineElement[];
|
||||
gsapScript: string | null;
|
||||
styles: string | null;
|
||||
resolution: CanvasResolution;
|
||||
keyframes: Record<string, Keyframe[]>;
|
||||
stageZoomKeyframes: StageZoomKeyframe[];
|
||||
}
|
||||
|
||||
function getElementType(el: Element): TimelineElementType | null {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (tag === "video") return "video";
|
||||
if (tag === "img") return "image";
|
||||
if (tag === "audio") return "audio";
|
||||
// Check for explicit data-type attribute first
|
||||
const dataType = el.getAttribute("data-type");
|
||||
if (dataType === "composition") return "composition";
|
||||
if (dataType === "text") return "text";
|
||||
// Fall back to tag-based detection for backwards compatibility
|
||||
if (
|
||||
tag === "div" ||
|
||||
tag === "p" ||
|
||||
tag === "h1" ||
|
||||
tag === "h2" ||
|
||||
tag === "h3" ||
|
||||
tag === "span"
|
||||
) {
|
||||
return "text";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getElementName(el: Element): string {
|
||||
const dataName = el.getAttribute("data-name");
|
||||
if (dataName) return dataName;
|
||||
|
||||
const type = getElementType(el);
|
||||
if (type === "text") {
|
||||
const text = el.textContent?.trim().slice(0, 30) || "Text";
|
||||
return text.length === 30 ? text + "..." : text;
|
||||
}
|
||||
|
||||
const src = el.getAttribute("src");
|
||||
if (src) {
|
||||
const filename = src.split("/").pop() || src;
|
||||
return filename.split("?")[0] ?? filename;
|
||||
}
|
||||
|
||||
return el.id || el.className?.toString().split(" ")[0] || "Element";
|
||||
}
|
||||
|
||||
function getZIndex(el: Element): number {
|
||||
const dataLayer = el.getAttribute("data-layer");
|
||||
if (dataLayer) return parseInt(dataLayer, 10) || 0;
|
||||
|
||||
const style = (el as HTMLElement).style?.zIndex;
|
||||
if (style) return parseInt(style, 10) || 0;
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function parseResolutionFromCss(doc: Document, cssText: string | null): CanvasResolution {
|
||||
const stage = doc.getElementById("stage") || doc.querySelector("#stage");
|
||||
if (stage) {
|
||||
const inlineStyle = (stage as HTMLElement).style;
|
||||
if (inlineStyle?.width && inlineStyle?.height) {
|
||||
const w = parseInt(inlineStyle.width, 10);
|
||||
const h = parseInt(inlineStyle.height, 10);
|
||||
if (w && h) {
|
||||
return resolveResolutionFromDimensions(w, h);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (cssText) {
|
||||
const stageMatch = cssText.match(
|
||||
/#stage\s*\{[^}]*width:\s*(\d+)px[^}]*height:\s*(\d+)px[^}]*\}/,
|
||||
);
|
||||
if (stageMatch) {
|
||||
const w = parseInt(stageMatch[1] ?? "", 10);
|
||||
const h = parseInt(stageMatch[2] ?? "", 10);
|
||||
return resolveResolutionFromDimensions(w, h);
|
||||
}
|
||||
const stageMatchReverse = cssText.match(
|
||||
/#stage\s*\{[^}]*height:\s*(\d+)px[^}]*width:\s*(\d+)px[^}]*\}/,
|
||||
);
|
||||
if (stageMatchReverse) {
|
||||
const h = parseInt(stageMatchReverse[1] ?? "", 10);
|
||||
const w = parseInt(stageMatchReverse[2] ?? "", 10);
|
||||
return resolveResolutionFromDimensions(w, h);
|
||||
}
|
||||
}
|
||||
|
||||
return "portrait";
|
||||
}
|
||||
|
||||
function parseResolutionFromHtml(doc: Document): CanvasResolution | null {
|
||||
const htmlEl = doc.documentElement;
|
||||
if (!htmlEl) return null;
|
||||
const resolutionAttr = htmlEl.getAttribute("data-resolution");
|
||||
if (
|
||||
resolutionAttr === "landscape" ||
|
||||
resolutionAttr === "portrait" ||
|
||||
resolutionAttr === "landscape-4k" ||
|
||||
resolutionAttr === "portrait-4k" ||
|
||||
resolutionAttr === "square" ||
|
||||
resolutionAttr === "square-4k"
|
||||
) {
|
||||
return resolutionAttr;
|
||||
}
|
||||
|
||||
const widthAttr = htmlEl.getAttribute("data-composition-width");
|
||||
const heightAttr = htmlEl.getAttribute("data-composition-height");
|
||||
if (widthAttr && heightAttr) {
|
||||
const width = parseInt(widthAttr, 10);
|
||||
const height = parseInt(heightAttr, 10);
|
||||
if (width && height) {
|
||||
return resolveResolutionFromDimensions(width, height);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const UHD_SQUARE_MIN = 2160;
|
||||
const UHD_RECT_MIN = 3840;
|
||||
|
||||
function resolveResolutionFromDimensions(width: number, height: number): CanvasResolution {
|
||||
const longSide = Math.max(width, height);
|
||||
if (width === height) {
|
||||
return longSide >= UHD_SQUARE_MIN ? "square-4k" : "square";
|
||||
}
|
||||
const isLandscape = width > height;
|
||||
const isUhd = longSide >= UHD_RECT_MIN;
|
||||
if (isLandscape) return isUhd ? "landscape-4k" : "landscape";
|
||||
return isUhd ? "portrait-4k" : "portrait";
|
||||
}
|
||||
|
||||
export function parseHtml(html: string): ParsedHtml {
|
||||
const withIds = ensureHfIds(html);
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(withIds, "text/html");
|
||||
|
||||
const elements: TimelineElement[] = [];
|
||||
const keyframes: Record<string, Keyframe[]> = {};
|
||||
let idCounter = 0;
|
||||
|
||||
const htmlEl = doc.documentElement;
|
||||
if (!htmlEl) {
|
||||
throw new CompositionHtmlParseError("parseHtml: input HTML is empty or could not be parsed");
|
||||
}
|
||||
const customStylesAttr = htmlEl.getAttribute("data-custom-styles");
|
||||
let customStyles: string | null = null;
|
||||
if (customStylesAttr) {
|
||||
try {
|
||||
customStyles = JSON.parse(customStylesAttr);
|
||||
} catch {
|
||||
customStyles = customStylesAttr;
|
||||
}
|
||||
}
|
||||
|
||||
const timedElements = doc.querySelectorAll("[data-start]");
|
||||
|
||||
timedElements.forEach((el) => {
|
||||
const type = getElementType(el);
|
||||
if (!type) return;
|
||||
|
||||
const start = parseFloat(el.getAttribute("data-start") || "0");
|
||||
const dataEnd = el.getAttribute("data-end");
|
||||
|
||||
let duration: number;
|
||||
if (dataEnd) {
|
||||
duration = Math.max(0, parseFloat(dataEnd) - start);
|
||||
} else {
|
||||
duration = 5;
|
||||
}
|
||||
|
||||
// R1: stable hf- id minted by ensureHfIds above; clips just read it.
|
||||
// Legacy/migration note: ensureHfIds pins a pre-existing `data-hf-id`, and
|
||||
// the generator emits `data-hf-id="${element.id}"`. So a clip authored
|
||||
// before R1 with `id="my-title"` round-trips as `data-hf-id="my-title"` —
|
||||
// a non-`hf-`-shaped but still stable, exact-match handle. This is safe
|
||||
// indefinitely: targeting uses exact `[data-hf-id="…"]` match (it does not
|
||||
// require the hf- prefix). ensureHfIds skips elements that already carry
|
||||
// data-hf-id, so legacy values are NOT re-minted automatically — they
|
||||
// persist until the user re-saves the composition through Studio. Not a bug.
|
||||
const id = el.getAttribute("data-hf-id") || el.id || `element-${++idCounter}`;
|
||||
const name = getElementName(el);
|
||||
const zIndex = getZIndex(el);
|
||||
|
||||
// Parse data-keyframes attribute if present
|
||||
const keyframesAttr = el.getAttribute("data-keyframes");
|
||||
if (keyframesAttr) {
|
||||
try {
|
||||
const parsedKeyframes = JSON.parse(keyframesAttr);
|
||||
if (Array.isArray(parsedKeyframes) && parsedKeyframes.length > 0) {
|
||||
keyframes[id] = parsedKeyframes;
|
||||
}
|
||||
} catch {
|
||||
// skip invalid keyframes
|
||||
}
|
||||
}
|
||||
|
||||
// Parse transform properties (x, y, scale, opacity)
|
||||
const xAttr = el.getAttribute("data-x");
|
||||
const yAttr = el.getAttribute("data-y");
|
||||
const scaleAttr = el.getAttribute("data-scale");
|
||||
const opacityAttr = el.getAttribute("data-opacity");
|
||||
const x = xAttr ? parseFloat(xAttr) : undefined;
|
||||
const y = yAttr ? parseFloat(yAttr) : undefined;
|
||||
const scale = scaleAttr ? parseFloat(scaleAttr) : undefined;
|
||||
const opacity = opacityAttr ? parseFloat(opacityAttr) : undefined;
|
||||
|
||||
if (type === "text") {
|
||||
const textEl = el.firstElementChild;
|
||||
const content = textEl?.textContent || name;
|
||||
const color = el.getAttribute("data-color") || undefined;
|
||||
const fontSizeAttr = el.getAttribute("data-font-size");
|
||||
const fontSize = fontSizeAttr ? parseInt(fontSizeAttr, 10) : undefined;
|
||||
const fontWeightAttr = el.getAttribute("data-font-weight");
|
||||
const fontWeight = fontWeightAttr ? parseInt(fontWeightAttr, 10) : undefined;
|
||||
const fontFamily = el.getAttribute("data-font-family") || undefined;
|
||||
const textShadowAttr = el.getAttribute("data-text-shadow");
|
||||
const textShadow = textShadowAttr === "false" ? false : undefined;
|
||||
|
||||
// Parse outline properties
|
||||
const textOutlineAttr = el.getAttribute("data-text-outline");
|
||||
const textOutline = textOutlineAttr === "true" ? true : undefined;
|
||||
const textOutlineColor = el.getAttribute("data-text-outline-color") || undefined;
|
||||
const textOutlineWidthAttr = el.getAttribute("data-text-outline-width");
|
||||
const textOutlineWidth = textOutlineWidthAttr
|
||||
? parseInt(textOutlineWidthAttr, 10)
|
||||
: undefined;
|
||||
|
||||
// Parse highlight properties
|
||||
const textHighlightAttr = el.getAttribute("data-text-highlight");
|
||||
const textHighlight = textHighlightAttr === "true" ? true : undefined;
|
||||
const textHighlightColor = el.getAttribute("data-text-highlight-color") || undefined;
|
||||
const textHighlightPaddingAttr = el.getAttribute("data-text-highlight-padding");
|
||||
const textHighlightPadding = textHighlightPaddingAttr
|
||||
? parseInt(textHighlightPaddingAttr, 10)
|
||||
: undefined;
|
||||
const textHighlightRadiusAttr = el.getAttribute("data-text-highlight-radius");
|
||||
const textHighlightRadius = textHighlightRadiusAttr
|
||||
? parseInt(textHighlightRadiusAttr, 10)
|
||||
: undefined;
|
||||
|
||||
const textElement: TimelineTextElement = {
|
||||
id,
|
||||
type: "text",
|
||||
name,
|
||||
content,
|
||||
startTime: start,
|
||||
duration,
|
||||
zIndex,
|
||||
x,
|
||||
y,
|
||||
scale,
|
||||
opacity,
|
||||
color,
|
||||
fontSize,
|
||||
fontWeight,
|
||||
fontFamily,
|
||||
textShadow,
|
||||
textOutline,
|
||||
textOutlineColor,
|
||||
textOutlineWidth,
|
||||
textHighlight,
|
||||
textHighlightColor,
|
||||
textHighlightPadding,
|
||||
textHighlightRadius,
|
||||
};
|
||||
elements.push(textElement);
|
||||
} else if (type === "composition") {
|
||||
// Composition is a div container with iframe inside
|
||||
const iframe = el.querySelector("iframe");
|
||||
const src = iframe?.getAttribute("src") || el.getAttribute("src") || "";
|
||||
const compositionId = el.getAttribute("data-composition-id") || "";
|
||||
const sourceDurationAttr = el.getAttribute("data-source-duration");
|
||||
const sourceDuration = sourceDurationAttr ? parseFloat(sourceDurationAttr) : undefined;
|
||||
const sourceWidthAttr = el.getAttribute("data-source-width");
|
||||
const sourceWidth = sourceWidthAttr ? parseInt(sourceWidthAttr, 10) : undefined;
|
||||
const sourceHeightAttr = el.getAttribute("data-source-height");
|
||||
const sourceHeight = sourceHeightAttr ? parseInt(sourceHeightAttr, 10) : undefined;
|
||||
|
||||
// Parse variable values if present
|
||||
const variableValuesAttr = el.getAttribute("data-variable-values");
|
||||
let variableValues: Record<string, string | number | boolean> | undefined;
|
||||
if (variableValuesAttr) {
|
||||
try {
|
||||
variableValues = JSON.parse(variableValuesAttr);
|
||||
} catch {
|
||||
// skip invalid variable values
|
||||
}
|
||||
}
|
||||
|
||||
const compositionElement: TimelineCompositionElement = {
|
||||
id,
|
||||
type: "composition",
|
||||
name,
|
||||
src,
|
||||
compositionId,
|
||||
startTime: start,
|
||||
duration,
|
||||
zIndex,
|
||||
x,
|
||||
y,
|
||||
scale,
|
||||
opacity,
|
||||
sourceDuration,
|
||||
sourceWidth,
|
||||
sourceHeight,
|
||||
variableValues,
|
||||
};
|
||||
elements.push(compositionElement);
|
||||
} else {
|
||||
if (!MEDIA_TYPES.has(type)) return;
|
||||
|
||||
const src = el.getAttribute("src") || "";
|
||||
const mediaStartTimeAttr = el.getAttribute("data-media-start");
|
||||
const mediaStartTime = mediaStartTimeAttr ? parseFloat(mediaStartTimeAttr) : undefined;
|
||||
const sourceDurationAttr = el.getAttribute("data-source-duration");
|
||||
const sourceDuration = sourceDurationAttr ? parseFloat(sourceDurationAttr) : undefined;
|
||||
const isArollAttr = el.getAttribute("data-aroll");
|
||||
const isAroll = isArollAttr === "true" ? true : undefined;
|
||||
const volumeAttr = el.getAttribute("data-volume");
|
||||
const volume = volumeAttr ? parseFloat(volumeAttr) : undefined;
|
||||
const hasAudioAttr = el.getAttribute("data-has-audio");
|
||||
const hasAudio = hasAudioAttr === "true" ? true : undefined;
|
||||
|
||||
const mediaElement: TimelineMediaElement = {
|
||||
id,
|
||||
type: type as "video" | "image" | "audio",
|
||||
name,
|
||||
src,
|
||||
startTime: start,
|
||||
duration,
|
||||
zIndex,
|
||||
x,
|
||||
y,
|
||||
scale,
|
||||
opacity,
|
||||
mediaStartTime,
|
||||
sourceDuration,
|
||||
isAroll,
|
||||
volume,
|
||||
hasAudio,
|
||||
};
|
||||
elements.push(mediaElement);
|
||||
}
|
||||
});
|
||||
|
||||
const scriptTags = findScriptElementsDeep(doc);
|
||||
let gsapScript: string | null = null;
|
||||
|
||||
for (const script of scriptTags) {
|
||||
const src = script.getAttribute("src");
|
||||
if (src && src.includes("gsap")) continue;
|
||||
|
||||
const content = script.textContent?.trim();
|
||||
if (content && (content.includes("gsap") || content.includes("timeline"))) {
|
||||
gsapScript = content;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize keyframes (clamp negative time, convert absolute -> relative if detected)
|
||||
for (const element of elements) {
|
||||
const elementKeyframes = keyframes[element.id];
|
||||
if (!elementKeyframes || elementKeyframes.length === 0) continue;
|
||||
|
||||
const baseX = element.x ?? 0;
|
||||
const baseY = element.y ?? 0;
|
||||
const baseScale =
|
||||
element.type === "video" || element.type === "image" || element.type === "composition"
|
||||
? ((element as TimelineMediaElement | TimelineCompositionElement).scale ?? 1)
|
||||
: 1;
|
||||
|
||||
keyframes[element.id] = normalizeKeyframes(elementKeyframes, baseX, baseY, baseScale);
|
||||
}
|
||||
|
||||
const styleTags = doc.querySelectorAll("style");
|
||||
const allStyles =
|
||||
Array.from(styleTags)
|
||||
.map((s) => s.textContent?.trim())
|
||||
.filter(Boolean)
|
||||
.join("\n\n") || null;
|
||||
|
||||
const customStyleTags = Array.from(styleTags).filter(
|
||||
(s) => s.getAttribute("data-hf-custom") === "true",
|
||||
);
|
||||
const customStylesFromTags =
|
||||
customStyleTags
|
||||
.map((s) => s.textContent?.trim())
|
||||
.filter(Boolean)
|
||||
.join("\n\n") || null;
|
||||
|
||||
const styles = customStyles ?? customStylesFromTags ?? null;
|
||||
|
||||
const resolution = parseResolutionFromHtml(doc) ?? parseResolutionFromCss(doc, allStyles);
|
||||
|
||||
// Parse stage zoom keyframes from zoom container
|
||||
const stageZoomKeyframes = parseStageZoomKeyframes(doc);
|
||||
|
||||
return {
|
||||
elements,
|
||||
gsapScript,
|
||||
styles,
|
||||
resolution,
|
||||
keyframes,
|
||||
stageZoomKeyframes,
|
||||
};
|
||||
}
|
||||
|
||||
function parseStageZoomKeyframes(doc: Document): StageZoomKeyframe[] {
|
||||
const zoomContainer = doc.getElementById("stage-zoom-container");
|
||||
if (!zoomContainer) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const zoomKeyframesAttr = zoomContainer.getAttribute("data-zoom-keyframes");
|
||||
if (!zoomKeyframesAttr) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(zoomKeyframesAttr);
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed.filter(
|
||||
(kf): kf is StageZoomKeyframe =>
|
||||
typeof kf === "object" &&
|
||||
kf !== null &&
|
||||
typeof kf.id === "string" &&
|
||||
typeof kf.time === "number" &&
|
||||
typeof kf.zoom === "object" &&
|
||||
kf.zoom !== null &&
|
||||
typeof kf.zoom.scale === "number" &&
|
||||
typeof kf.zoom.focusX === "number" &&
|
||||
typeof kf.zoom.focusY === "number",
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// skip invalid zoom keyframes
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function normalizeKeyframes(
|
||||
keyframes: Keyframe[],
|
||||
baseX: number,
|
||||
baseY: number,
|
||||
baseScale: number,
|
||||
): Keyframe[] {
|
||||
const timeEpsilon = 0.001;
|
||||
const valueEpsilon = 0.00001;
|
||||
|
||||
const hasBaseCheck = (value: number | undefined, base: number): boolean =>
|
||||
value !== undefined && Math.abs(value - base) <= valueEpsilon && Math.abs(base) > valueEpsilon;
|
||||
|
||||
const timeZeroKeyframes = keyframes.filter((kf) => Math.abs(kf.time) <= timeEpsilon);
|
||||
|
||||
const treatAsAbsolute = timeZeroKeyframes.some((kf) => {
|
||||
const props = kf.properties || {};
|
||||
if (
|
||||
hasBaseCheck(props.x, baseX) ||
|
||||
hasBaseCheck(props.y, baseY) ||
|
||||
(baseScale !== 1 && hasBaseCheck(props.scale, baseScale))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
return keyframes.map((kf) => {
|
||||
const normalizedProps: Partial<KeyframeProperties> = {};
|
||||
for (const [key, value] of Object.entries(kf.properties || {})) {
|
||||
if (typeof value !== "number") continue;
|
||||
if (treatAsAbsolute && key === "x") {
|
||||
normalizedProps.x = value - baseX;
|
||||
} else if (treatAsAbsolute && key === "y") {
|
||||
normalizedProps.y = value - baseY;
|
||||
} else if (treatAsAbsolute && key === "scale") {
|
||||
normalizedProps.scale = baseScale !== 0 ? value / baseScale : value;
|
||||
} else {
|
||||
(normalizedProps as Record<string, number>)[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...kf,
|
||||
time: Math.max(0, kf.time),
|
||||
properties: normalizedProps,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function updateElementInHtml(
|
||||
html: string,
|
||||
elementId: string,
|
||||
updates: Partial<TimelineElement>,
|
||||
): string {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, "text/html");
|
||||
|
||||
const el = doc.getElementById(elementId) || queryByAttr(doc, "data-name", elementId);
|
||||
if (!el) return html;
|
||||
|
||||
if (updates.startTime !== undefined) {
|
||||
el.setAttribute("data-start", String(updates.startTime));
|
||||
if (el.hasAttribute("data-end") && updates.duration !== undefined) {
|
||||
el.setAttribute("data-end", String(updates.startTime + updates.duration));
|
||||
}
|
||||
}
|
||||
|
||||
if (updates.duration !== undefined) {
|
||||
const start = parseFloat(el.getAttribute("data-start") || "0");
|
||||
el.setAttribute("data-end", String(start + updates.duration));
|
||||
el.removeAttribute("data-duration"); // Clean up legacy
|
||||
}
|
||||
|
||||
if (updates.name !== undefined) {
|
||||
el.setAttribute("data-name", updates.name);
|
||||
}
|
||||
|
||||
if (updates.zIndex !== undefined) {
|
||||
el.setAttribute("data-layer", String(updates.zIndex));
|
||||
}
|
||||
|
||||
// Handle media-specific property
|
||||
if ("src" in updates && updates.src !== undefined) {
|
||||
el.setAttribute("src", updates.src);
|
||||
}
|
||||
|
||||
// Handle text-specific properties
|
||||
if ("content" in updates && updates.content !== undefined) {
|
||||
const textEl = el.firstElementChild;
|
||||
if (textEl) {
|
||||
textEl.textContent = updates.content;
|
||||
}
|
||||
}
|
||||
|
||||
if ("color" in updates && updates.color !== undefined) {
|
||||
el.setAttribute("data-color", updates.color);
|
||||
}
|
||||
|
||||
if ("fontSize" in updates && updates.fontSize !== undefined) {
|
||||
el.setAttribute("data-font-size", String(updates.fontSize));
|
||||
}
|
||||
|
||||
if ("textShadow" in updates) {
|
||||
if (updates.textShadow === false) {
|
||||
el.setAttribute("data-text-shadow", "false");
|
||||
} else {
|
||||
el.removeAttribute("data-text-shadow");
|
||||
}
|
||||
}
|
||||
|
||||
// Handle volume property for audio/video
|
||||
if ("volume" in updates) {
|
||||
if (updates.volume !== undefined && updates.volume !== 1) {
|
||||
el.setAttribute("data-volume", String(updates.volume));
|
||||
} else {
|
||||
el.removeAttribute("data-volume");
|
||||
}
|
||||
}
|
||||
|
||||
// Handle hasAudio property for videos
|
||||
if ("hasAudio" in updates) {
|
||||
if (updates.hasAudio === true) {
|
||||
el.setAttribute("data-has-audio", "true");
|
||||
} else {
|
||||
el.removeAttribute("data-has-audio");
|
||||
}
|
||||
}
|
||||
|
||||
if (!doc.documentElement) {
|
||||
throw new CompositionHtmlParseError(
|
||||
"updateElementInHtml: input HTML is empty or could not be parsed",
|
||||
);
|
||||
}
|
||||
return "<!DOCTYPE html>\n" + doc.documentElement.outerHTML;
|
||||
}
|
||||
|
||||
export function addElementToHtml(
|
||||
html: string,
|
||||
element: Omit<TimelineElement, "id"> & { id?: string },
|
||||
): { html: string; id: string } {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, "text/html");
|
||||
|
||||
if (!doc.documentElement) {
|
||||
throw new CompositionHtmlParseError(
|
||||
"addElementToHtml: input HTML is empty or could not be parsed",
|
||||
);
|
||||
}
|
||||
|
||||
// Prefer zoom container, fall back to stage, then container, then body
|
||||
const container =
|
||||
doc.querySelector("#stage-zoom-container") ||
|
||||
doc.querySelector(".container") ||
|
||||
doc.querySelector("#stage") ||
|
||||
doc.body;
|
||||
|
||||
if (!container) {
|
||||
throw new CompositionHtmlParseError("addElementToHtml: input HTML has no <body>");
|
||||
}
|
||||
|
||||
const id = element.id || `element-${Date.now()}`;
|
||||
|
||||
let newEl: Element;
|
||||
|
||||
function applyMediaAttrs(el: Element, mediaEl: TimelineMediaElement): void {
|
||||
if (mediaEl.src) el.setAttribute("src", mediaEl.src);
|
||||
if (mediaEl.volume !== undefined && mediaEl.volume !== 1) {
|
||||
el.setAttribute("data-volume", String(mediaEl.volume));
|
||||
}
|
||||
}
|
||||
|
||||
switch (element.type) {
|
||||
case "video": {
|
||||
const mediaEl = element as TimelineMediaElement;
|
||||
newEl = doc.createElement("video");
|
||||
newEl.setAttribute("muted", "");
|
||||
newEl.setAttribute("playsinline", "");
|
||||
applyMediaAttrs(newEl, mediaEl);
|
||||
if (mediaEl.hasAudio) {
|
||||
newEl.setAttribute("data-has-audio", "true");
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "image": {
|
||||
const mediaEl = element as TimelineMediaElement;
|
||||
newEl = doc.createElement("img");
|
||||
if (mediaEl.src) newEl.setAttribute("src", mediaEl.src);
|
||||
newEl.setAttribute("alt", element.name);
|
||||
break;
|
||||
}
|
||||
case "audio": {
|
||||
const mediaEl = element as TimelineMediaElement;
|
||||
newEl = doc.createElement("audio");
|
||||
applyMediaAttrs(newEl, mediaEl);
|
||||
break;
|
||||
}
|
||||
case "text":
|
||||
default: {
|
||||
const textEl = element as TimelineTextElement;
|
||||
newEl = doc.createElement("div");
|
||||
const textContent = doc.createElement("div");
|
||||
textContent.textContent = textEl.content || element.name;
|
||||
newEl.appendChild(textContent);
|
||||
if (textEl.color) {
|
||||
newEl.setAttribute("data-color", textEl.color);
|
||||
}
|
||||
if (textEl.fontSize) {
|
||||
newEl.setAttribute("data-font-size", String(textEl.fontSize));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
newEl.id = id;
|
||||
newEl.setAttribute("data-start", String(element.startTime));
|
||||
newEl.setAttribute("data-end", String(element.startTime + element.duration));
|
||||
newEl.setAttribute("data-layer", String(element.zIndex));
|
||||
newEl.setAttribute("data-name", element.name);
|
||||
|
||||
container.appendChild(newEl);
|
||||
|
||||
return {
|
||||
html: "<!DOCTYPE html>\n" + doc.documentElement.outerHTML,
|
||||
id,
|
||||
};
|
||||
}
|
||||
|
||||
function selectorTargetsId(selector: string, id: string): boolean {
|
||||
return (
|
||||
selector === `#${id}` ||
|
||||
selector === `[data-hf-id="${id}"]` ||
|
||||
selector === `[data-hf-id='${id}']`
|
||||
);
|
||||
}
|
||||
|
||||
function stripGsapForId(script: string, elementId: string): string {
|
||||
// Re-parse after every removal. Animation ids are count-based (positional), so
|
||||
// removing one tween renumbers the survivors — ids captured from a single
|
||||
// up-front parse go stale and silently no-op, orphaning later tweens on the
|
||||
// now-deleted element. Always remove the FIRST still-matching animation in a
|
||||
// freshly-parsed script until none remain.
|
||||
let current = script;
|
||||
for (;;) {
|
||||
const parsed = parseGsapScriptAcornForWrite(current);
|
||||
if (!parsed) return current;
|
||||
const match = parsed.located.find((l) =>
|
||||
selectorTargetsId(l.animation.targetSelector, elementId),
|
||||
);
|
||||
if (!match) return current;
|
||||
const updated = removeAnimationFromScript(current, match.id);
|
||||
// Guard against a non-removing match (would otherwise loop forever).
|
||||
if (updated === current) return current;
|
||||
current = updated;
|
||||
}
|
||||
}
|
||||
|
||||
function cascadeRemoveGsapById(doc: Document, elementId: string): void {
|
||||
for (const script of findScriptElementsDeep(doc)) {
|
||||
const text = script.textContent ?? "";
|
||||
if (!text.includes("gsap") && !text.includes("ScrollTrigger")) continue;
|
||||
const updated = stripGsapForId(text, elementId);
|
||||
if (updated !== text) script.textContent = updated;
|
||||
}
|
||||
}
|
||||
|
||||
export function removeElementFromHtml(html: string, elementId: string): string {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, "text/html");
|
||||
if (!doc.documentElement) {
|
||||
throw new CompositionHtmlParseError(
|
||||
"removeElementFromHtml: input HTML is empty or could not be parsed",
|
||||
);
|
||||
}
|
||||
doc.getElementById(elementId)?.remove();
|
||||
cascadeRemoveGsapById(doc, elementId);
|
||||
return "<!DOCTYPE html>\n" + doc.documentElement.outerHTML;
|
||||
}
|
||||
|
||||
export interface CompositionMetadata {
|
||||
compositionId: string | null;
|
||||
compositionDuration: number | null;
|
||||
variables: CompositionVariable[];
|
||||
}
|
||||
|
||||
export function extractCompositionMetadata(html: string): CompositionMetadata {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, "text/html");
|
||||
const htmlEl = doc.documentElement;
|
||||
if (!htmlEl) {
|
||||
throw new CompositionHtmlParseError(
|
||||
"extractCompositionMetadata: input HTML is empty or could not be parsed",
|
||||
);
|
||||
}
|
||||
|
||||
const compositionId = htmlEl.getAttribute("data-composition-id");
|
||||
const durationStr = htmlEl.getAttribute("data-composition-duration");
|
||||
const compositionDuration = durationStr ? parseFloat(durationStr) : null;
|
||||
|
||||
// TODO(template-var-carriers): reads `<html>` only. A template/fragment comp
|
||||
// that declares variables on its `[data-composition-id]` root div (the
|
||||
// dual-carrier contract from #2081) reports no variables when its metadata is
|
||||
// extracted standalone (e.g. CLI --variables validation of a sub-comp file).
|
||||
const variables = parseCompositionVariables(htmlEl);
|
||||
|
||||
return {
|
||||
compositionId,
|
||||
compositionDuration:
|
||||
compositionDuration && isFinite(compositionDuration) ? compositionDuration : null,
|
||||
variables,
|
||||
};
|
||||
}
|
||||
|
||||
export { parseCompositionVariables };
|
||||
|
||||
export function validateCompositionHtml(html: string): ValidationResult {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, "text/html");
|
||||
const htmlEl = doc.documentElement;
|
||||
|
||||
if (!htmlEl) {
|
||||
return {
|
||||
valid: false,
|
||||
errors: ["Composition HTML is empty or could not be parsed"],
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
|
||||
const compositionId = htmlEl.getAttribute("data-composition-id");
|
||||
if (!compositionId) {
|
||||
errors.push("Missing data-composition-id attribute on <html> element");
|
||||
}
|
||||
|
||||
const durationStr = htmlEl.getAttribute("data-composition-duration");
|
||||
if (!durationStr) {
|
||||
errors.push("Missing data-composition-duration attribute on <html> element");
|
||||
} else {
|
||||
const duration = parseFloat(durationStr);
|
||||
if (!isFinite(duration) || duration <= 0) {
|
||||
errors.push("data-composition-duration must be a positive finite number");
|
||||
}
|
||||
}
|
||||
|
||||
const stage = doc.getElementById("stage");
|
||||
if (!stage) {
|
||||
errors.push("Missing #stage element");
|
||||
}
|
||||
|
||||
if (/\son\w+\s*=/i.test(html)) {
|
||||
errors.push("Inline event handlers (onclick, onload, etc.) not allowed");
|
||||
}
|
||||
|
||||
if (/javascript\s*:/i.test(html)) {
|
||||
errors.push("javascript: URLs not allowed");
|
||||
}
|
||||
|
||||
const scripts = findScriptElementsDeep(doc);
|
||||
if (scripts.length > 2) {
|
||||
warnings.push("Multiple script tags detected - only GSAP CDN and main script expected");
|
||||
}
|
||||
|
||||
for (const gsapScript of extractGsapScripts(doc)) {
|
||||
const gsapValidation = validateCompositionGsap(gsapScript);
|
||||
errors.push(...gsapValidation.errors);
|
||||
warnings.push(...gsapValidation.warnings);
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
function findScriptElementsDeep(doc: Document): Element[] {
|
||||
const scripts: Element[] = [];
|
||||
walkCompositionDescendants(doc, (el) => {
|
||||
if (el.tagName.toLowerCase() === "script") scripts.push(el);
|
||||
});
|
||||
return scripts;
|
||||
}
|
||||
|
||||
function extractGsapScripts(doc: Document): string[] {
|
||||
const scripts = findScriptElementsDeep(doc);
|
||||
const gsapScripts: string[] = [];
|
||||
for (const script of scripts) {
|
||||
const content = script.textContent || "";
|
||||
if (
|
||||
content.includes("gsap.timeline") ||
|
||||
content.includes(".set(") ||
|
||||
content.includes(".to(")
|
||||
) {
|
||||
gsapScripts.push(content);
|
||||
}
|
||||
}
|
||||
return gsapScripts;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export * from "./types.js";
|
||||
export * from "./gsapParserExports.js";
|
||||
export * from "./htmlParser.js";
|
||||
export * from "./hfIds.js";
|
||||
export * from "./subCompositionValidity.js";
|
||||
export * from "./outputResolutionCompatibility.js";
|
||||
export { unrollComputedTimeline } from "./gsapUnroll.js";
|
||||
export { queryByAttr } from "./utils/cssSelector.js";
|
||||
|
||||
// Pure, browser-safe composition primitives shared by the linter (so it can
|
||||
// consume them without depending on @hyperframes/core). The Node-only asset
|
||||
// path helpers live behind the ./asset-paths subpath to keep this entry
|
||||
// browser-safe.
|
||||
export { decodeUrlPathVariants } from "./utils/urlPath.js";
|
||||
export { scanVariableUsage, type VariableUsageScan } from "./variableUsage.js";
|
||||
export {
|
||||
FONT_ALIAS_MAP,
|
||||
FONT_ALIAS_KEYS,
|
||||
CANONICAL_FONT_DISPLAY_NAMES,
|
||||
resolveAliasDisplayName,
|
||||
} from "./fontAliases.js";
|
||||
@@ -0,0 +1,144 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { checkOutputResolutionCompatibility } from "./outputResolutionCompatibility.js";
|
||||
|
||||
describe("checkOutputResolutionCompatibility", () => {
|
||||
it("returns ok when no outputResolution is requested", () => {
|
||||
expect(
|
||||
checkOutputResolutionCompatibility({
|
||||
compositionWidth: 1080,
|
||||
compositionHeight: 1920,
|
||||
outputResolution: undefined,
|
||||
}),
|
||||
).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("returns ok when the preset matches the composition exactly", () => {
|
||||
expect(
|
||||
checkOutputResolutionCompatibility({
|
||||
compositionWidth: 1920,
|
||||
compositionHeight: 1080,
|
||||
outputResolution: "landscape",
|
||||
}).ok,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns ok when supersampling by an integer factor (same aspect)", () => {
|
||||
// 1920×1080 composition, 4K landscape preset (3840×2160) → 2× DPR.
|
||||
expect(
|
||||
checkOutputResolutionCompatibility({
|
||||
compositionWidth: 1920,
|
||||
compositionHeight: 1080,
|
||||
outputResolution: "landscape-4k",
|
||||
}).ok,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
describe("aspect-ratio mismatch (the dominant P1-3 failure)", () => {
|
||||
it("flags a landscape preset against a portrait composition and suggests portrait", () => {
|
||||
const result = checkOutputResolutionCompatibility({
|
||||
compositionWidth: 1080,
|
||||
compositionHeight: 1920,
|
||||
outputResolution: "landscape",
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.kind).toBe("aspect-mismatch");
|
||||
expect(result.suggestedResolution).toBe("portrait");
|
||||
expect(result.message).toContain("does not match the aspect ratio");
|
||||
expect(result.message).toContain("--resolution portrait");
|
||||
});
|
||||
|
||||
it("preserves the 4K tier when suggesting a swap", () => {
|
||||
// portrait composition + landscape-4k preset → suggest portrait-4k, not portrait.
|
||||
const result = checkOutputResolutionCompatibility({
|
||||
compositionWidth: 1080,
|
||||
compositionHeight: 1920,
|
||||
outputResolution: "landscape-4k",
|
||||
});
|
||||
expect(result.suggestedResolution).toBe("portrait-4k");
|
||||
expect(result.message).toContain("--resolution portrait-4k");
|
||||
});
|
||||
|
||||
it("flags a portrait preset against a landscape composition and suggests landscape", () => {
|
||||
const result = checkOutputResolutionCompatibility({
|
||||
compositionWidth: 1920,
|
||||
compositionHeight: 1080,
|
||||
outputResolution: "portrait",
|
||||
});
|
||||
expect(result.suggestedResolution).toBe("landscape");
|
||||
});
|
||||
|
||||
it("does not suggest a preset for a custom aspect ratio with no preset match", () => {
|
||||
// 2000×1000 (2:1) has no matching preset → no unambiguous swap.
|
||||
const result = checkOutputResolutionCompatibility({
|
||||
compositionWidth: 2000,
|
||||
compositionHeight: 1000,
|
||||
outputResolution: "portrait",
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.kind).toBe("aspect-mismatch");
|
||||
expect(result.suggestedResolution).toBeUndefined();
|
||||
expect(result.message).toContain("omit --resolution");
|
||||
});
|
||||
});
|
||||
|
||||
describe("alpha / HDR incompatibility", () => {
|
||||
it("flags alpha output combined with outputResolution", () => {
|
||||
const result = checkOutputResolutionCompatibility({
|
||||
compositionWidth: 1920,
|
||||
compositionHeight: 1080,
|
||||
outputResolution: "landscape-4k",
|
||||
alphaRequested: true,
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.kind).toBe("alpha-incompatible");
|
||||
expect(result.message).toContain("alpha output");
|
||||
expect(result.message).toContain("--format mp4");
|
||||
});
|
||||
|
||||
it("flags HDR combined with outputResolution", () => {
|
||||
const result = checkOutputResolutionCompatibility({
|
||||
compositionWidth: 1920,
|
||||
compositionHeight: 1080,
|
||||
outputResolution: "landscape-4k",
|
||||
hdrRequested: true,
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.kind).toBe("hdr-incompatible");
|
||||
expect(result.message).toContain("hdrMode='force-hdr'");
|
||||
});
|
||||
|
||||
it("prioritizes HDR over aspect mismatch when both are wrong", () => {
|
||||
const result = checkOutputResolutionCompatibility({
|
||||
compositionWidth: 1080,
|
||||
compositionHeight: 1920,
|
||||
outputResolution: "landscape",
|
||||
hdrRequested: true,
|
||||
});
|
||||
expect(result.kind).toBe("hdr-incompatible");
|
||||
});
|
||||
});
|
||||
|
||||
describe("scale constraints", () => {
|
||||
it("flags downsampling (preset smaller than composition)", () => {
|
||||
// 3840×2160 composition, landscape (1920×1080) preset → 0.5× DPR.
|
||||
const result = checkOutputResolutionCompatibility({
|
||||
compositionWidth: 3840,
|
||||
compositionHeight: 2160,
|
||||
outputResolution: "landscape",
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.kind).toBe("downsampling");
|
||||
});
|
||||
|
||||
it("flags a non-integer scale factor for a same-aspect custom composition", () => {
|
||||
// 1000×1000 (square aspect) + square preset (1080×1080) → 1.08× DPR.
|
||||
const result = checkOutputResolutionCompatibility({
|
||||
compositionWidth: 1000,
|
||||
compositionHeight: 1000,
|
||||
outputResolution: "square",
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.kind).toBe("non-integer-scale");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Shared "is this `outputResolution` preset compatible with the composition?"
|
||||
* check.
|
||||
*
|
||||
* The `--resolution` render flag (a `CanvasResolution` preset) is chosen
|
||||
* independently of the composition it renders, so a portrait composition
|
||||
* (1080×1920) rendered with `--resolution landscape` (1920×1080) is a common
|
||||
* mistake — especially from AI agents that pick a preset by habit rather than
|
||||
* by inspecting the composition. Historically this surfaced as a cryptic
|
||||
* `Error` thrown deep inside the render compiler (`resolveDeviceScaleFactor`
|
||||
* in `@hyperframes/producer`), after the browser and ffmpeg had already spun
|
||||
* up, with a message that named the mismatch but not the fix.
|
||||
*
|
||||
* This module gives every consumer (the render pre-flight in the CLI, the
|
||||
* producer's `resolveDeviceScaleFactor`, and any future lint rule) a single,
|
||||
* dependency-free definition of "is this preset usable for this composition"
|
||||
* — including a suggested preset when there's an unambiguously-correct swap —
|
||||
* so the same check can run *before* a render is attempted (loud, actionable,
|
||||
* cheap) and again as defense-in-depth inside the pipeline.
|
||||
*
|
||||
* It lives in `@hyperframes/parsers` (rather than `@hyperframes/core`) because
|
||||
* both `core`/`producer` and `lint` may need it, and `lint` cannot depend on
|
||||
* `core`. The geometry it needs (`CANVAS_DIMENSIONS`) already lives here.
|
||||
*/
|
||||
|
||||
import { CANVAS_DIMENSIONS, VALID_CANVAS_RESOLUTIONS, type CanvasResolution } from "./types.js";
|
||||
|
||||
export type OutputResolutionIssueKind =
|
||||
| "hdr-incompatible"
|
||||
| "alpha-incompatible"
|
||||
| "aspect-mismatch"
|
||||
| "downsampling"
|
||||
| "non-integer-scale";
|
||||
|
||||
export interface OutputResolutionCompatibility {
|
||||
ok: boolean;
|
||||
/** Present when `ok` is false. */
|
||||
kind?: OutputResolutionIssueKind;
|
||||
/** Human-readable, actionable message suitable for direct display. */
|
||||
message?: string;
|
||||
/**
|
||||
* A preset whose orientation/aspect ratio matches the composition, when one
|
||||
* exists and is an unambiguous swap for the user's intent. Present only for
|
||||
* `aspect-mismatch`. Consumers may surface this as a suggestion ("did you
|
||||
* mean `--resolution portrait`?"); it is intentionally *not* auto-applied —
|
||||
* silently swapping a user-supplied flag changes their stated intent.
|
||||
*/
|
||||
suggestedResolution?: CanvasResolution;
|
||||
}
|
||||
|
||||
const OK: OutputResolutionCompatibility = { ok: true };
|
||||
|
||||
/**
|
||||
* Find the preset that shares the composition's aspect ratio and resolution
|
||||
* tier (HD vs 4K) as the user's chosen preset. E.g. a portrait composition
|
||||
* with `--resolution landscape-4k` suggests `portrait-4k`, not `portrait`,
|
||||
* preserving the user's intent to render at 4K while fixing the orientation.
|
||||
*
|
||||
* Returns `undefined` when no preset matches the composition's aspect ratio
|
||||
* (e.g. a custom, non-preset composition aspect ratio) — in that case there
|
||||
* is no unambiguous swap to suggest.
|
||||
*/
|
||||
function suggestMatchingPreset(
|
||||
compositionWidth: number,
|
||||
compositionHeight: number,
|
||||
chosen: CanvasResolution,
|
||||
): CanvasResolution | undefined {
|
||||
const aspectMatches: CanvasResolution[] = VALID_CANVAS_RESOLUTIONS.filter((preset) => {
|
||||
const { width, height } = CANVAS_DIMENSIONS[preset];
|
||||
// Integer-safe aspect compare (cross-multiplication), matching the
|
||||
// producer's mismatch check exactly.
|
||||
return width * compositionHeight === height * compositionWidth;
|
||||
});
|
||||
if (aspectMatches.length === 0) return undefined;
|
||||
|
||||
// Prefer the aspect-matching preset in the same resolution tier as the chosen
|
||||
// one so we don't silently downgrade 4K → HD (or vice versa). Tier is keyed
|
||||
// off the `-4k` suffix rather than long-side pixels: a 4K *square* (2160×2160)
|
||||
// has a shorter long side than 4K landscape (3840), so a pixel-based compare
|
||||
// would fail to recognise `square-4k` as the 4K peer and downgrade to `square`.
|
||||
const chosenIs4k = chosen.endsWith("-4k");
|
||||
const sameTier = aspectMatches.find((preset) => preset.endsWith("-4k") === chosenIs4k);
|
||||
return sameTier ?? aspectMatches[0];
|
||||
}
|
||||
|
||||
function describeOrientation(width: number, height: number): string {
|
||||
if (width > height) return "landscape";
|
||||
if (width < height) return "portrait";
|
||||
return "square";
|
||||
}
|
||||
|
||||
/** Build the aspect-ratio-mismatch result, including a preset suggestion. */
|
||||
function buildAspectMismatch(
|
||||
compositionWidth: number,
|
||||
compositionHeight: number,
|
||||
outputResolution: CanvasResolution,
|
||||
target: { width: number; height: number },
|
||||
): OutputResolutionCompatibility {
|
||||
const suggestedResolution = suggestMatchingPreset(
|
||||
compositionWidth,
|
||||
compositionHeight,
|
||||
outputResolution,
|
||||
);
|
||||
const suggestion = suggestedResolution
|
||||
? ` The composition is ${describeOrientation(compositionWidth, compositionHeight)} — ` +
|
||||
`use --resolution ${suggestedResolution} instead.`
|
||||
: ` Pick a preset whose orientation matches, or omit --resolution to render at the composition's native dimensions.`;
|
||||
return {
|
||||
ok: false,
|
||||
kind: "aspect-mismatch",
|
||||
suggestedResolution,
|
||||
message:
|
||||
`outputResolution ${outputResolution} (${target.width}×${target.height}) ` +
|
||||
`does not match the aspect ratio of the composition ` +
|
||||
`(${compositionWidth}×${compositionHeight}).` +
|
||||
suggestion,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether rendering a composition of the given dimensions with the given
|
||||
* `outputResolution` preset (and alpha/HDR modes) is supported.
|
||||
*
|
||||
* Pure and dependency-free — the single source of truth for the constraints
|
||||
* `resolveDeviceScaleFactor` enforces, so the CLI can run the exact same check
|
||||
* as a pre-flight before any browser/ffmpeg work.
|
||||
*
|
||||
* @param outputResolution The chosen preset, or `undefined` when the render
|
||||
* uses the composition's native dimensions (always compatible).
|
||||
*/
|
||||
export function checkOutputResolutionCompatibility(input: {
|
||||
compositionWidth: number;
|
||||
compositionHeight: number;
|
||||
outputResolution: CanvasResolution | undefined;
|
||||
alphaRequested?: boolean;
|
||||
hdrRequested?: boolean;
|
||||
}): OutputResolutionCompatibility {
|
||||
const { compositionWidth, compositionHeight, outputResolution } = input;
|
||||
if (!outputResolution) return OK;
|
||||
|
||||
if (input.hdrRequested) {
|
||||
return {
|
||||
ok: false,
|
||||
kind: "hdr-incompatible",
|
||||
message:
|
||||
`outputResolution cannot be combined with hdrMode='force-hdr'. ` +
|
||||
`HDR rendering composites at composition dimensions and does not yet ` +
|
||||
`support supersampling. Pick one or render in two passes.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (input.alphaRequested) {
|
||||
return {
|
||||
ok: false,
|
||||
kind: "alpha-incompatible",
|
||||
message:
|
||||
`outputResolution cannot be combined with alpha output (--format webm|mov|png-sequence). ` +
|
||||
`The alpha screenshot path does not yet apply deviceScaleFactor and would silently ` +
|
||||
`produce composition-resolution frames. Render alpha at composition resolution and ` +
|
||||
`upscale separately, or use --format mp4.`,
|
||||
};
|
||||
}
|
||||
|
||||
const target = CANVAS_DIMENSIONS[outputResolution];
|
||||
// Aspect-ratio compare via cross-multiplication so the equality is integer-
|
||||
// safe. Float division (`target.width / compositionWidth`) loses precision
|
||||
// for non-power-of-2 ratios (e.g. cinema 4K 4096×2160 = 1.8963…) and a
|
||||
// future preset could trip a false-mismatch on otherwise valid input.
|
||||
if (target.width * compositionHeight !== target.height * compositionWidth) {
|
||||
return buildAspectMismatch(compositionWidth, compositionHeight, outputResolution, target);
|
||||
}
|
||||
|
||||
// Aspect ratios match → widthRatio === heightRatio. Compute once.
|
||||
const widthRatio = target.width / compositionWidth;
|
||||
if (widthRatio < 1) {
|
||||
return {
|
||||
ok: false,
|
||||
kind: "downsampling",
|
||||
message:
|
||||
`outputResolution ${outputResolution} (${target.width}×${target.height}) ` +
|
||||
`is smaller than the composition (${compositionWidth}×${compositionHeight}). ` +
|
||||
`Downsampling via --resolution is not supported.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!Number.isInteger(widthRatio)) {
|
||||
return {
|
||||
ok: false,
|
||||
kind: "non-integer-scale",
|
||||
message:
|
||||
`outputResolution ${outputResolution} requires a non-integer ` +
|
||||
`device scale factor (${widthRatio}×) to upsample from ` +
|
||||
`${compositionWidth}×${compositionHeight}. ` +
|
||||
`Pick a preset that's an integer multiple, or rescale the composition.`,
|
||||
};
|
||||
}
|
||||
|
||||
return OK;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
rewriteAssetPath,
|
||||
rewriteCssAssetUrls,
|
||||
rewriteInlineStyleAssetUrls,
|
||||
} from "./rewriteSubCompPaths.js";
|
||||
|
||||
describe("rewriteAssetPath", () => {
|
||||
it("rewrites `../` against the sub-composition dir", () => {
|
||||
expect(rewriteAssetPath("compositions/scene.html", "../icon.svg")).toBe("icon.svg");
|
||||
});
|
||||
|
||||
it("leaves plain relative paths untouched", () => {
|
||||
expect(rewriteAssetPath("compositions/scene.html", "assets/logo.png")).toBe("assets/logo.png");
|
||||
});
|
||||
|
||||
it("leaves absolute URLs and data URIs untouched", () => {
|
||||
expect(rewriteAssetPath("compositions/scene.html", "https://x/y")).toBe("https://x/y");
|
||||
expect(rewriteAssetPath("compositions/scene.html", "data:image/png;base64,AA")).toBe(
|
||||
"data:image/png;base64,AA",
|
||||
);
|
||||
expect(rewriteAssetPath("compositions/scene.html", "#hash")).toBe("#hash");
|
||||
});
|
||||
|
||||
// Regression guard for a Windows-only bug: the rewriter used to import
|
||||
// `path` (native) and emit `:\fonts\brand.woff2` — native `join` used
|
||||
// backslashes, and `resolve("/", x).slice(1)` chopped the `D` off a
|
||||
// `D:\…` absolute path. URLs must be POSIX regardless of host OS.
|
||||
it("never emits backslashes on any platform", () => {
|
||||
const out = rewriteAssetPath("compositions/nested/scene.html", "../../fonts/brand.woff2");
|
||||
expect(out).toBe("fonts/brand.woff2");
|
||||
expect(out).not.toMatch(/\\/);
|
||||
expect(out).not.toMatch(/^:/);
|
||||
});
|
||||
|
||||
it("CSS url(...) rewrites also stay POSIX under nesting", () => {
|
||||
const css = `@font-face { src: url("../../fonts/brand.woff2") format("woff2"); }`;
|
||||
const out = rewriteCssAssetUrls(css, "compositions/nested/scene.html");
|
||||
expect(out).toContain(`url("fonts/brand.woff2")`);
|
||||
expect(out).not.toMatch(/\\/);
|
||||
expect(out).not.toMatch(/:\\/);
|
||||
});
|
||||
|
||||
it("rewrites CSS urls inside inline style attributes", () => {
|
||||
const elements = [{ style: `background-image: url("../cover.png")` }];
|
||||
|
||||
rewriteInlineStyleAssetUrls(
|
||||
elements,
|
||||
"compositions/scene.html",
|
||||
(el) => el.style,
|
||||
(el, value) => {
|
||||
el.style = value;
|
||||
},
|
||||
);
|
||||
|
||||
expect(elements[0]?.style).toBe(`background-image: url("cover.png")`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Rewrite relative asset paths in sub-composition content so they resolve
|
||||
* correctly after the content is inlined into the root document.
|
||||
*
|
||||
* A sub-composition at "compositions/scene.html" referencing "../icon.svg"
|
||||
* means the project root — but after inlining into root index.html, the
|
||||
* "../" escapes the project directory and causes 404s. This function
|
||||
* resolves each relative path against the sub-composition's directory,
|
||||
* then normalizes it to be relative to the project root.
|
||||
*
|
||||
* Used by both the core bundler (preview) and the producer compiler (render)
|
||||
* to ensure consistent behavior.
|
||||
*/
|
||||
|
||||
// URL paths in HTML output are POSIX regardless of host OS — use the `posix`
|
||||
// submodule so Windows builds don't emit backslash-separated paths (or worse,
|
||||
// drive-letter-prefixed artifacts from `resolve("/", ...)`).
|
||||
import { posix } from "path";
|
||||
const { join, resolve, dirname } = posix;
|
||||
|
||||
import { CSS_URL_RE, PATH_ATTRS, isNonRelativeUrl } from "./assetPaths.js";
|
||||
|
||||
const isAbsoluteOrSpecial = isNonRelativeUrl;
|
||||
|
||||
/**
|
||||
* Returns true only for paths that traverse up with `../`.
|
||||
* Plain relative paths like `assets/foo.svg` are already correct from the
|
||||
* root perspective — the browser resolves them against the served root, which
|
||||
* is the project root, so they don't need rewriting.
|
||||
*/
|
||||
function needsRewrite(val: string): boolean {
|
||||
return val.startsWith("../") || val === "..";
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite a single relative path from a sub-composition's context to the
|
||||
* project root context.
|
||||
*
|
||||
* @param compSrcPath - The `data-composition-src` value (e.g. "compositions/scene.html")
|
||||
* @param relativePath - The asset path to rewrite (e.g. "../icon.svg")
|
||||
* @returns The rewritten path relative to project root (e.g. "icon.svg"), or
|
||||
* the original path if no rewriting is needed.
|
||||
*/
|
||||
export function rewriteAssetPath(compSrcPath: string, relativePath: string): string {
|
||||
if (isAbsoluteOrSpecial(relativePath)) return relativePath;
|
||||
if (!needsRewrite(relativePath)) return relativePath;
|
||||
const compDir = dirname(compSrcPath);
|
||||
if (!compDir || compDir === ".") return relativePath;
|
||||
const resolved = join(compDir, relativePath);
|
||||
const normalized = resolve("/", resolved).slice(1);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite all relative `src` and `href` attributes on elements within a
|
||||
* DOM tree, adjusting paths from the sub-composition's directory context
|
||||
* to the project root.
|
||||
*
|
||||
* @param elements - Iterable of DOM elements to scan (e.g. from querySelectorAll)
|
||||
* @param compSrcPath - The `data-composition-src` value
|
||||
* @param getAttr - Function to read an attribute from an element
|
||||
* @param setAttr - Function to set an attribute on an element
|
||||
*/
|
||||
export function rewriteAssetPaths<T>(
|
||||
elements: Iterable<T>,
|
||||
compSrcPath: string,
|
||||
getAttr: (el: T, attr: string) => string | null | undefined,
|
||||
setAttr: (el: T, attr: string, value: string) => void,
|
||||
): void {
|
||||
for (const el of elements) {
|
||||
for (const attr of PATH_ATTRS) {
|
||||
const val = (getAttr(el, attr) || "").trim();
|
||||
const rewritten = rewriteAssetPath(compSrcPath, val);
|
||||
if (rewritten !== val) {
|
||||
setAttr(el, attr, rewritten);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite CSS url(...) references inside inline style attributes.
|
||||
*/
|
||||
export function rewriteInlineStyleAssetUrls<T>(
|
||||
elements: Iterable<T>,
|
||||
compSrcPath: string,
|
||||
getStyle: (el: T) => string | null | undefined,
|
||||
setStyle: (el: T, value: string) => void,
|
||||
): void {
|
||||
const compDir = dirname(compSrcPath);
|
||||
if (!compDir || compDir === ".") return;
|
||||
|
||||
for (const el of elements) {
|
||||
const style = getStyle(el);
|
||||
if (!style) continue;
|
||||
const rewritten = rewriteCssAssetUrls(style, compSrcPath);
|
||||
if (rewritten !== style) {
|
||||
setStyle(el, rewritten);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite CSS url(...) references in a sub-composition's inline styles so
|
||||
* ../foo.woff2 remains valid after the CSS is hoisted into the root document.
|
||||
*/
|
||||
export function rewriteCssAssetUrls(cssText: string, compSrcPath: string): string {
|
||||
if (!cssText) return cssText;
|
||||
return cssText.replace(CSS_URL_RE, (full, quote: string, rawUrl: string) => {
|
||||
const urlValue = (rawUrl || "").trim();
|
||||
const rewritten = rewriteAssetPath(compSrcPath, urlValue);
|
||||
if (rewritten === urlValue) return full;
|
||||
return `url(${quote || ""}${rewritten}${quote || ""})`;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./slideshow.types.js";
|
||||
export * from "./parseSlideshow.js";
|
||||
export { isSceneLikeCompositionId } from "./sceneId.js";
|
||||
@@ -0,0 +1,218 @@
|
||||
// packages/core/src/slideshow/parseSlideshow.test.ts
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseSlideshowManifest, resolveSlideshow } from "./parseSlideshow";
|
||||
|
||||
const ISLAND = `<!doctype html><html><body>
|
||||
<script type="application/hyperframes-slideshow+json">
|
||||
{ "slides": [
|
||||
{ "sceneId": "a", "fragments": [2.0, 1.0], "hotspots": [{ "id": "h1", "label": "Why?", "target": "deep" }] },
|
||||
{ "sceneId": "b" }
|
||||
],
|
||||
"slideSequences": [ { "id": "deep", "label": "Deep dive", "slides": [ { "sceneId": "c" } ] } ]
|
||||
}
|
||||
</script>
|
||||
</body></html>`;
|
||||
|
||||
const SCENES = [
|
||||
{ id: "a", start: 0, duration: 5 },
|
||||
{ id: "b", start: 5, duration: 5 },
|
||||
{ id: "c", start: 10, duration: 3 },
|
||||
];
|
||||
|
||||
describe("parseSlideshowManifest", () => {
|
||||
it("returns null when no island present", () => {
|
||||
expect(parseSlideshowManifest("<html></html>")).toBeNull();
|
||||
});
|
||||
|
||||
it("parses the island JSON", () => {
|
||||
const m = parseSlideshowManifest(ISLAND);
|
||||
expect(m?.slides.length).toBe(2);
|
||||
expect(m?.slideSequences?.[0].id).toBe("deep");
|
||||
});
|
||||
|
||||
it("throws when slideSequences is present but not an array", () => {
|
||||
const html = `<script type="application/hyperframes-slideshow+json">
|
||||
{ "slides": [{ "sceneId": "a" }], "slideSequences": {} }
|
||||
</script>`;
|
||||
expect(() => parseSlideshowManifest(html)).toThrow();
|
||||
});
|
||||
|
||||
it("rejects a non-object manifest (e.g. a JSON array)", () => {
|
||||
const html = `<script type="application/hyperframes-slideshow+json">[42, null]</script>`;
|
||||
expect(() => parseSlideshowManifest(html)).toThrow();
|
||||
});
|
||||
|
||||
it("throws when a slide entry is malformed (sceneId not a string)", () => {
|
||||
const html = `<script type="application/hyperframes-slideshow+json">
|
||||
{ "slides": [{ "sceneId": 42 }] }
|
||||
</script>`;
|
||||
expect(() => parseSlideshowManifest(html)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSlideshow", () => {
|
||||
it("resolves scene time ranges and sorts fragments", () => {
|
||||
const m = parseSlideshowManifest(ISLAND);
|
||||
if (!m) throw new Error("manifest expected");
|
||||
const { resolved, errors } = resolveSlideshow(m, SCENES);
|
||||
expect(errors).toEqual([]);
|
||||
expect(resolved.slides[0].start).toBe(0);
|
||||
expect(resolved.slides[0].end).toBe(5);
|
||||
expect(resolved.slides[0].fragments).toEqual([1.0, 2.0]); // sorted
|
||||
expect(resolved.sequences.deep.slides[0].start).toBe(10);
|
||||
});
|
||||
|
||||
it("honors explicit startTime/endTime overrides", () => {
|
||||
const m: import("./slideshow.types").SlideshowManifest = {
|
||||
slides: [{ sceneId: "a", startTime: 1, endTime: 4 }],
|
||||
};
|
||||
const { resolved } = resolveSlideshow(m, SCENES);
|
||||
expect(resolved.slides[0].start).toBe(1);
|
||||
expect(resolved.slides[0].end).toBe(4);
|
||||
});
|
||||
|
||||
it("reports an error for an unresolved sceneId", () => {
|
||||
const m: import("./slideshow.types").SlideshowManifest = {
|
||||
slides: [{ sceneId: "missing" }],
|
||||
};
|
||||
const { errors } = resolveSlideshow(m, SCENES);
|
||||
expect(errors.some((e) => e.includes("missing"))).toBe(true);
|
||||
});
|
||||
|
||||
it("flags duplicate slideSequence ids instead of silently overwriting", () => {
|
||||
const m: import("./slideshow.types").SlideshowManifest = {
|
||||
slides: [{ sceneId: "a" }],
|
||||
slideSequences: [
|
||||
{ id: "dup", label: "First", slides: [{ sceneId: "c" }] },
|
||||
{ id: "dup", label: "Second", slides: [{ sceneId: "c" }] },
|
||||
],
|
||||
};
|
||||
const { errors } = resolveSlideshow(m, SCENES);
|
||||
expect(errors.some((e) => e.includes("duplicate slideSequence id"))).toBe(true);
|
||||
});
|
||||
|
||||
it("reports an error for a fragment outside the slide range", () => {
|
||||
const m: import("./slideshow.types").SlideshowManifest = {
|
||||
slides: [{ sceneId: "a", fragments: [99] }],
|
||||
};
|
||||
const { errors } = resolveSlideshow(m, SCENES);
|
||||
expect(errors.some((e) => e.includes("fragment"))).toBe(true);
|
||||
});
|
||||
|
||||
it("reports an error for a hotspot target with no sequence", () => {
|
||||
const m: import("./slideshow.types").SlideshowManifest = {
|
||||
slides: [{ sceneId: "a", hotspots: [{ id: "h", label: "x", target: "nope" }] }],
|
||||
};
|
||||
const { errors } = resolveSlideshow(m, SCENES);
|
||||
expect(errors.some((e) => e.includes("nope"))).toBe(true);
|
||||
});
|
||||
|
||||
it("reports an error for overlapping main-line slides", () => {
|
||||
const m: import("./slideshow.types").SlideshowManifest = {
|
||||
slides: [
|
||||
{ sceneId: "a", startTime: 0, endTime: 6 },
|
||||
{ sceneId: "b", startTime: 5, endTime: 10 },
|
||||
],
|
||||
};
|
||||
const { errors } = resolveSlideshow(m, SCENES);
|
||||
expect(errors.some((e) => e.includes("overlap"))).toBe(true);
|
||||
});
|
||||
|
||||
// Partial-override cases
|
||||
it("fills missing endTime from scene when only startTime is provided and scene exists", () => {
|
||||
const m: import("./slideshow.types").SlideshowManifest = {
|
||||
slides: [{ sceneId: "a", startTime: 2 }],
|
||||
};
|
||||
const { resolved, errors } = resolveSlideshow(m, SCENES);
|
||||
expect(errors).toEqual([]);
|
||||
expect(resolved.slides[0].start).toBe(2);
|
||||
expect(resolved.slides[0].end).toBe(5); // scene a: start=0, duration=5
|
||||
});
|
||||
|
||||
it("fills missing startTime from scene when only endTime is provided and scene exists", () => {
|
||||
const m: import("./slideshow.types").SlideshowManifest = {
|
||||
slides: [{ sceneId: "a", endTime: 3 }],
|
||||
};
|
||||
const { resolved, errors } = resolveSlideshow(m, SCENES);
|
||||
expect(errors).toEqual([]);
|
||||
expect(resolved.slides[0].start).toBe(0); // scene a: start=0
|
||||
expect(resolved.slides[0].end).toBe(3);
|
||||
});
|
||||
|
||||
it("reports a clear error when only startTime is provided but scene is absent", () => {
|
||||
const m: import("./slideshow.types").SlideshowManifest = {
|
||||
slides: [{ sceneId: "x", startTime: 2 }],
|
||||
};
|
||||
const { errors } = resolveSlideshow(m, SCENES);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
// Must mention the missing bound (endTime), not the misleading "unresolved sceneId"
|
||||
expect(errors.some((e) => e.includes("endTime"))).toBe(true);
|
||||
expect(errors.some((e) => e.includes("unresolved sceneId"))).toBe(false);
|
||||
});
|
||||
|
||||
it("reports a clear error when only endTime is provided but scene is absent", () => {
|
||||
const m: import("./slideshow.types").SlideshowManifest = {
|
||||
slides: [{ sceneId: "x", endTime: 5 }],
|
||||
};
|
||||
const { errors } = resolveSlideshow(m, SCENES);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
// Must mention the missing bound (startTime), not the misleading "unresolved sceneId"
|
||||
expect(errors.some((e) => e.includes("startTime"))).toBe(true);
|
||||
expect(errors.some((e) => e.includes("unresolved sceneId"))).toBe(false);
|
||||
});
|
||||
|
||||
it("reports an error for an inverted explicit range (endTime <= startTime)", () => {
|
||||
const m: import("./slideshow.types").SlideshowManifest = {
|
||||
slides: [{ sceneId: "a", startTime: 5, endTime: 2 }],
|
||||
};
|
||||
const { errors } = resolveSlideshow(m, SCENES);
|
||||
expect(errors.some((e) => e.includes("endTime") && e.includes("startTime"))).toBe(true);
|
||||
});
|
||||
|
||||
it("de-duplicates fragments before resolving", () => {
|
||||
const m: import("./slideshow.types").SlideshowManifest = {
|
||||
slides: [{ sceneId: "a", fragments: [2, 1, 2, 1, 3] }],
|
||||
};
|
||||
const { resolved, errors } = resolveSlideshow(m, SCENES);
|
||||
expect(errors).toEqual([]);
|
||||
expect(resolved.slides[0].fragments).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it("reports an error for a hotspot targeting an empty sequence", () => {
|
||||
const m: import("./slideshow.types").SlideshowManifest = {
|
||||
slides: [{ sceneId: "a", hotspots: [{ id: "h", label: "x", target: "empty" }] }],
|
||||
slideSequences: [{ id: "empty", label: "Empty", slides: [] }],
|
||||
};
|
||||
const { errors } = resolveSlideshow(m, SCENES);
|
||||
expect(errors.some((e) => e.includes("empty sequence"))).toBe(true);
|
||||
});
|
||||
|
||||
it("full override with no scene produces no error", () => {
|
||||
const m: import("./slideshow.types").SlideshowManifest = {
|
||||
slides: [{ sceneId: "noexist", startTime: 1, endTime: 4 }],
|
||||
};
|
||||
const { resolved, errors } = resolveSlideshow(m, SCENES);
|
||||
expect(errors).toEqual([]);
|
||||
expect(resolved.slides[0].start).toBe(1);
|
||||
expect(resolved.slides[0].end).toBe(4);
|
||||
});
|
||||
|
||||
it("parses and carries through the per-slide autoplay flag", () => {
|
||||
const island = `<script type="application/hyperframes-slideshow+json">
|
||||
{ "slides": [ { "sceneId": "a", "autoplay": true }, { "sceneId": "b" } ] }
|
||||
</script>`;
|
||||
const m = parseSlideshowManifest(island);
|
||||
expect(m?.slides[0].autoplay).toBe(true);
|
||||
expect(m?.slides[1].autoplay).toBeUndefined();
|
||||
const { resolved } = resolveSlideshow(m!, SCENES);
|
||||
expect(resolved.slides[0].autoplay).toBe(true);
|
||||
expect(resolved.slides[1].autoplay).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects a manifest whose slide autoplay is not a boolean", () => {
|
||||
const island = `<script type="application/hyperframes-slideshow+json">
|
||||
{ "slides": [ { "sceneId": "a", "autoplay": "yes" } ] }
|
||||
</script>`;
|
||||
expect(() => parseSlideshowManifest(island)).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
// packages/core/src/slideshow/parseSlideshow.ts
|
||||
import type {
|
||||
SlideshowManifest,
|
||||
SlideRef,
|
||||
ResolvedSlide,
|
||||
ResolvedSlideshow,
|
||||
ResolvedSlideSequence,
|
||||
} from "./slideshow.types";
|
||||
|
||||
export const SLIDESHOW_ISLAND_TYPE = "application/hyperframes-slideshow+json";
|
||||
|
||||
/**
|
||||
* Builds the island <script> matcher. Capture group 1 = inner JSON.
|
||||
*
|
||||
* Factory (fresh RegExp per call) on purpose: a RegExp with the `g` flag carries
|
||||
* a mutable `lastIndex`, so callers that need `g` must call this each time rather
|
||||
* than caching a shared instance.
|
||||
*/
|
||||
export function slideshowIslandRegex(flags = "i"): RegExp {
|
||||
// Escape ALL regex metacharacters in the constant (CodeQL flags the incomplete
|
||||
// `\` escape). The constant has none today, but a complete escape is correct.
|
||||
const escaped = SLIDESHOW_ISLAND_TYPE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
return new RegExp(`<script[^>]*type=["']${escaped}["'][^>]*>([\\s\\S]*?)<\\/script>`, flags);
|
||||
}
|
||||
|
||||
interface SceneRange {
|
||||
id: string;
|
||||
start: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
/** Extract the JSON island from composition HTML. Returns null if absent. */
|
||||
export function parseSlideshowManifest(html: string): SlideshowManifest | null {
|
||||
// Match <script type="application/hyperframes-slideshow+json"> ... </script>
|
||||
const re = slideshowIslandRegex("i");
|
||||
const match = re.exec(html);
|
||||
if (!match || match[1] === undefined) return null;
|
||||
const raw = match[1].trim();
|
||||
if (raw.length === 0) return null;
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!isManifest(parsed)) {
|
||||
throw new Error("slideshow island is not a valid SlideshowManifest");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function isOptionalNumberArray(v: unknown): boolean {
|
||||
return v === undefined || (Array.isArray(v) && v.every((n) => typeof n === "number"));
|
||||
}
|
||||
|
||||
function isOptionalBoolean(v: unknown): v is boolean | undefined {
|
||||
return v === undefined || typeof v === "boolean";
|
||||
}
|
||||
|
||||
function isSlideRef(v: unknown): v is SlideRef {
|
||||
if (typeof v !== "object" || v === null) return false;
|
||||
const r = v as Record<string, unknown>;
|
||||
if (typeof r["sceneId"] !== "string") return false;
|
||||
if (!isOptionalNumberArray(r["fragments"])) return false;
|
||||
if (r["hotspots"] !== undefined && !Array.isArray(r["hotspots"])) return false;
|
||||
if (!isOptionalBoolean(r["autoplay"])) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function isSlideSequence(v: unknown): boolean {
|
||||
if (typeof v !== "object" || v === null) return false;
|
||||
const s = v as Record<string, unknown>;
|
||||
return (
|
||||
typeof s["id"] === "string" &&
|
||||
typeof s["label"] === "string" &&
|
||||
Array.isArray(s["slides"]) &&
|
||||
s["slides"].every(isSlideRef)
|
||||
);
|
||||
}
|
||||
|
||||
function isManifest(v: unknown): v is SlideshowManifest {
|
||||
if (typeof v !== "object" || v === null || Array.isArray(v)) return false;
|
||||
const o = v as Record<string, unknown>;
|
||||
if (!Array.isArray(o["slides"]) || !o["slides"].every(isSlideRef)) return false;
|
||||
if (o["slideSequences"] !== undefined) {
|
||||
if (!Array.isArray(o["slideSequences"]) || !o["slideSequences"].every(isSlideSequence))
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function missingBoundError(sceneId: string, missing: "startTime" | "endTime"): string {
|
||||
const present = missing === "startTime" ? "endTime" : "startTime";
|
||||
return `slide "${sceneId}" sets ${present} but ${missing} cannot be resolved (no scene "${sceneId}")`;
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function resolveTimeRange(
|
||||
ref: SlideRef,
|
||||
scene: SceneRange | undefined,
|
||||
errors: string[],
|
||||
): { start: number; end: number } {
|
||||
const { startTime, endTime, sceneId } = ref;
|
||||
|
||||
// Both explicit — use them directly, no scene needed.
|
||||
if (startTime !== undefined && endTime !== undefined) {
|
||||
return { start: startTime, end: endTime };
|
||||
}
|
||||
|
||||
// Neither explicit — resolve both from scene.
|
||||
if (startTime === undefined && endTime === undefined) {
|
||||
if (!scene) {
|
||||
errors.push(`slide references unresolved sceneId "${sceneId}"`);
|
||||
return { start: 0, end: 0 };
|
||||
}
|
||||
return { start: scene.start, end: scene.start + scene.duration };
|
||||
}
|
||||
|
||||
// Exactly one bound explicit — fill from scene, or report a clear error.
|
||||
if (!scene) {
|
||||
const missing = startTime === undefined ? "startTime" : "endTime";
|
||||
errors.push(missingBoundError(sceneId, missing));
|
||||
const bound = startTime ?? endTime ?? 0;
|
||||
return { start: bound, end: bound };
|
||||
}
|
||||
|
||||
return {
|
||||
start: startTime ?? scene.start,
|
||||
end: endTime ?? scene.start + scene.duration,
|
||||
};
|
||||
}
|
||||
|
||||
function validateFragments(
|
||||
sceneId: string,
|
||||
fragments: number[],
|
||||
start: number,
|
||||
end: number,
|
||||
errors: string[],
|
||||
): void {
|
||||
for (const f of fragments) {
|
||||
if (f < start || f > end) {
|
||||
errors.push(`slide "${sceneId}" fragment ${f} is outside range [${start}, ${end}]`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSlide(
|
||||
ref: SlideRef,
|
||||
sceneById: Map<string, SceneRange>,
|
||||
errors: string[],
|
||||
): ResolvedSlide {
|
||||
const scene = sceneById.get(ref.sceneId);
|
||||
const { start, end } = resolveTimeRange(ref, scene, errors);
|
||||
if (ref.startTime !== undefined && ref.endTime !== undefined && end <= start) {
|
||||
errors.push(`slide "${ref.sceneId}" has endTime (${end}) <= startTime (${start})`);
|
||||
}
|
||||
const fragments = [...new Set(ref.fragments ?? [])].sort((a, b) => a - b);
|
||||
validateFragments(ref.sceneId, fragments, start, end, errors);
|
||||
return { ...ref, start, end, fragments, hotspots: ref.hotspots ?? [] };
|
||||
}
|
||||
|
||||
export function resolveSlideshow(
|
||||
manifest: SlideshowManifest,
|
||||
scenes: SceneRange[],
|
||||
): { resolved: ResolvedSlideshow; errors: string[] } {
|
||||
const errors: string[] = [];
|
||||
const sceneById = new Map(scenes.map((s) => [s.id, s]));
|
||||
|
||||
const sequences: Record<string, ResolvedSlideSequence> = {};
|
||||
for (const seq of manifest.slideSequences ?? []) {
|
||||
// Flag duplicate sequence ids rather than silently overwriting the earlier one.
|
||||
if (Object.prototype.hasOwnProperty.call(sequences, seq.id)) {
|
||||
errors.push(`duplicate slideSequence id "${seq.id}" — only the last definition is kept`);
|
||||
}
|
||||
sequences[seq.id] = {
|
||||
id: seq.id,
|
||||
label: seq.label,
|
||||
slides: seq.slides.map((s) => resolveSlide(s, sceneById, errors)),
|
||||
};
|
||||
}
|
||||
|
||||
const slides = manifest.slides.map((s) => resolveSlide(s, sceneById, errors));
|
||||
|
||||
// Validate hotspot targets.
|
||||
const allSlides = [...slides, ...Object.values(sequences).flatMap((s) => s.slides)];
|
||||
for (const slide of allSlides) {
|
||||
for (const h of slide.hotspots) {
|
||||
const seq = sequences[h.target];
|
||||
if (!seq) {
|
||||
errors.push(`hotspot "${h.id}" targets unknown sequence "${h.target}"`);
|
||||
} else if (seq.slides.length === 0) {
|
||||
errors.push(`hotspot "${h.id}" targets empty sequence "${h.target}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate no main-line overlap (sorted by start; adjacent compare).
|
||||
const ordered = [...slides].sort((a, b) => a.start - b.start);
|
||||
for (let i = 1; i < ordered.length; i++) {
|
||||
const prev = ordered[i - 1];
|
||||
const curr = ordered[i];
|
||||
if (prev !== undefined && curr !== undefined && curr.start < prev.end) {
|
||||
errors.push(`main-line slides "${prev.sceneId}" and "${curr.sceneId}" overlap`);
|
||||
}
|
||||
}
|
||||
|
||||
return { resolved: { slides, sequences }, errors };
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// packages/core/src/slideshow/sceneId.ts
|
||||
|
||||
/**
|
||||
* Whether a composition id names a "scene-like" composition — i.e. a real slide
|
||||
* scene, not the root timeline (`main`) or a non-scene overlay (captions, ambient
|
||||
* layers). Shared by the runtime scene-window computation and the slideshow lint
|
||||
* rule so the two can never drift.
|
||||
*/
|
||||
export function isSceneLikeCompositionId(compositionId: string): boolean {
|
||||
const normalized = compositionId.trim().toLowerCase();
|
||||
if (!normalized || normalized === "main") return false;
|
||||
if (normalized.includes("caption")) return false;
|
||||
if (normalized.includes("ambient")) return false;
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// packages/core/src/slideshow/slideshow.types.ts
|
||||
|
||||
/** Current manifest schema version. Stamped on persist so future schema
|
||||
* changes can detect and migrate older islands. */
|
||||
export const SLIDESHOW_MANIFEST_VERSION = 1;
|
||||
|
||||
/** Raw author-facing shapes parsed from the JSON island. */
|
||||
export interface SlideshowManifest {
|
||||
/** Schema version (absent on pre-versioning islands → treat as 1). */
|
||||
version?: number;
|
||||
slides: SlideRef[];
|
||||
slideSequences?: SlideSequence[];
|
||||
}
|
||||
|
||||
export interface SlideRef {
|
||||
sceneId: string;
|
||||
startTime?: number;
|
||||
endTime?: number;
|
||||
notes?: string;
|
||||
fragments?: number[];
|
||||
hotspots?: SlideHotspot[];
|
||||
/**
|
||||
* When true, the slide's first `<video>` plays automatically on enter (the
|
||||
* presenter lands on the slide and the clip plays). The slideshow still holds
|
||||
* — it never auto-advances — so the presenter clicks Next when ready.
|
||||
* Defaults to false. Use it when the video is the slide's primary content and
|
||||
* its natural end is the cue to advance, not for background/ambient clips.
|
||||
*/
|
||||
autoplay?: boolean;
|
||||
// Reserved — TTS deferred. Parsed and carried, never consumed.
|
||||
ttsScript?: string;
|
||||
ttsAudioUrl?: string;
|
||||
ttsDurationMs?: number;
|
||||
}
|
||||
|
||||
export interface SlideHotspot {
|
||||
id: string;
|
||||
label: string;
|
||||
target: string; // references a SlideSequence.id
|
||||
region?: { x: number; y: number; w: number; h: number }; // % of slide
|
||||
}
|
||||
|
||||
export interface SlideSequence {
|
||||
id: string;
|
||||
label: string;
|
||||
slides: SlideRef[];
|
||||
}
|
||||
|
||||
/** A slide with its time range resolved from the matching scene. */
|
||||
export interface ResolvedSlide extends SlideRef {
|
||||
start: number;
|
||||
end: number;
|
||||
fragments: number[]; // always present, sorted, defaulted to []
|
||||
hotspots: SlideHotspot[]; // always present, defaulted to []
|
||||
}
|
||||
|
||||
export interface ResolvedSlideSequence {
|
||||
id: string;
|
||||
label: string;
|
||||
slides: ResolvedSlide[];
|
||||
}
|
||||
|
||||
export interface ResolvedSlideshow {
|
||||
slides: ResolvedSlide[];
|
||||
sequences: Record<string, ResolvedSlideSequence>; // keyed by sequence id
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { generateSpringEaseData, SPRING_PRESETS } from "./springEase";
|
||||
|
||||
/** Parse an SVG-path CustomEase string into {x, y} pairs. */
|
||||
function parsePairs(data: string): { x: number; y: number }[] {
|
||||
// Strip "M0,0 L" prefix, then split on whitespace between coordinate pairs
|
||||
const body = data.replace(/^M0,0\s+L/, "");
|
||||
const tokens = body.split(/\s+/);
|
||||
return [
|
||||
{ x: 0, y: 0 }, // from M0,0
|
||||
...tokens.map((tok) => {
|
||||
const [xStr, yStr] = tok.split(",");
|
||||
return { x: Number(xStr), y: Number(yStr) };
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
describe("generateSpringEaseData", () => {
|
||||
it("generates a valid SVG-path CustomEase data string", () => {
|
||||
const data = generateSpringEaseData(1, 180, 12);
|
||||
expect(typeof data).toBe("string");
|
||||
// Must start with M0,0 (SVG moveTo)
|
||||
expect(data.startsWith("M0,0")).toBe(true);
|
||||
// Must contain L (lineTo) segments
|
||||
expect(data).toContain(" L");
|
||||
const pairs = parsePairs(data);
|
||||
expect(pairs.length).toBeGreaterThan(10);
|
||||
// First point at origin, last at (1,1)
|
||||
expect(pairs[0]).toEqual({ x: 0, y: 0 });
|
||||
expect(pairs[pairs.length - 1]).toEqual({ x: 1, y: 1 });
|
||||
});
|
||||
|
||||
it("underdamped spring produces overshoot", () => {
|
||||
const data = generateSpringEaseData(1, 180, 8); // low damping = bouncy
|
||||
const pairs = parsePairs(data);
|
||||
const hasOvershoot = pairs.some((p) => p.y > 1.01);
|
||||
expect(hasOvershoot).toBe(true);
|
||||
});
|
||||
|
||||
it("critically damped spring has no overshoot", () => {
|
||||
const mass = 1;
|
||||
const stiffness = 100;
|
||||
const criticalDamping = 2 * Math.sqrt(stiffness * mass); // zeta = 1
|
||||
const data = generateSpringEaseData(mass, stiffness, criticalDamping);
|
||||
const pairs = parsePairs(data);
|
||||
const maxY = Math.max(...pairs.map((p) => p.y));
|
||||
expect(maxY).toBeLessThanOrEqual(1.005);
|
||||
});
|
||||
|
||||
it("overdamped spring has no overshoot and monotonically increases", () => {
|
||||
// zeta > 1 — heavy damping
|
||||
const data = generateSpringEaseData(1, 100, 30);
|
||||
const pairs = parsePairs(data);
|
||||
const maxY = Math.max(...pairs.map((p) => p.y));
|
||||
expect(maxY).toBeLessThanOrEqual(1.005);
|
||||
// Monotonically non-decreasing (within floating point tolerance)
|
||||
for (let i = 1; i < pairs.length; i++) {
|
||||
expect(pairs[i].y).toBeGreaterThanOrEqual(pairs[i - 1].y - 0.001);
|
||||
}
|
||||
});
|
||||
|
||||
it("all presets generate valid data", () => {
|
||||
for (const preset of SPRING_PRESETS) {
|
||||
const data = generateSpringEaseData(preset.mass, preset.stiffness, preset.damping);
|
||||
expect(data.length).toBeGreaterThan(0);
|
||||
expect(data.startsWith("M0,0")).toBe(true);
|
||||
const pairs = parsePairs(data);
|
||||
expect(pairs.length).toBeGreaterThan(50);
|
||||
}
|
||||
});
|
||||
|
||||
it("output x values span [0,1] monotonically", () => {
|
||||
const data = generateSpringEaseData(1, 180, 12);
|
||||
const pairs = parsePairs(data);
|
||||
expect(pairs[0].x).toBe(0);
|
||||
expect(pairs[pairs.length - 1].x).toBe(1);
|
||||
for (let i = 1; i < pairs.length; i++) {
|
||||
expect(pairs[i].x).toBeGreaterThan(pairs[i - 1].x - 0.0001);
|
||||
expect(pairs[i].x).toBeLessThanOrEqual(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("respects custom step count", () => {
|
||||
const data = generateSpringEaseData(1, 100, 15, 60);
|
||||
const pairs = parsePairs(data);
|
||||
// 60 steps + the M0,0 origin = 61 points
|
||||
expect(pairs.length).toBe(61);
|
||||
});
|
||||
});
|
||||
@@ -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(" ")}`;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*
|
||||
* T2 — Stable id spec (spec for R1).
|
||||
*
|
||||
* These tests define what "stable hf- id" means BEFORE R1 implements it.
|
||||
* They are intentionally red until R1 lands.
|
||||
*
|
||||
* Currently failing (spec): tests 1, 2, 3 — parser assigns `element-N` not `hf-xxxx`.
|
||||
* Currently passing (baseline): tests 4, 5, 6, 7 — these already hold and must not regress.
|
||||
*
|
||||
* Scope: id assignment and stability only. Round-trip fidelity is T1 territory.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseHtml } from "./htmlParser.js";
|
||||
import { serialize } from "./test-utils.js";
|
||||
|
||||
describe("T2 — stable element ids (spec for R1)", () => {
|
||||
// --- Spec (red until R1) ---
|
||||
|
||||
it("[spec] elements without an id get a hf- prefixed id at parse", () => {
|
||||
const html = `<html><body><div id="stage">
|
||||
<img src="logo.svg" data-start="0" data-end="5" data-name="Logo" />
|
||||
<div data-start="0" data-end="5" data-name="Card"><div>Text</div></div>
|
||||
</div></body></html>`;
|
||||
const { elements } = parseHtml(html);
|
||||
for (const el of elements) {
|
||||
expect(el.id).toMatch(/^hf-/);
|
||||
}
|
||||
});
|
||||
|
||||
it("[spec] generated hf- ids match /^hf-[a-z0-9]{4}$/", () => {
|
||||
const html = `<html><body><div id="stage">
|
||||
<div data-start="0" data-end="5" data-name="Unnamed"><div>X</div></div>
|
||||
<video data-start="1" data-end="6" src="v.mp4" data-name="Clip"></video>
|
||||
</div></body></html>`;
|
||||
const { elements } = parseHtml(html);
|
||||
const noPreExistingId = elements.filter((e) => e.id !== "stage");
|
||||
for (const el of noPreExistingId) {
|
||||
expect(el.id).toMatch(/^hf-[a-z0-9]{4}$/);
|
||||
}
|
||||
});
|
||||
|
||||
it("[spec] adding an element before existing ones does not change existing ids", () => {
|
||||
const base = `<html><body><div id="stage">
|
||||
<div data-start="0" data-end="5" data-name="AlphaEl"><div>A</div></div>
|
||||
<div data-start="1" data-end="6" data-name="BetaEl"><div>B</div></div>
|
||||
</div></body></html>`;
|
||||
const withPrepend = `<html><body><div id="stage">
|
||||
<div data-start="0" data-end="4" data-name="NewEl"><div>New</div></div>
|
||||
<div data-start="0" data-end="5" data-name="AlphaEl"><div>A</div></div>
|
||||
<div data-start="1" data-end="6" data-name="BetaEl"><div>B</div></div>
|
||||
</div></body></html>`;
|
||||
const baseAlpha = parseHtml(base).elements.find((e) => e.name === "AlphaEl");
|
||||
const extendedAlpha = parseHtml(withPrepend).elements.find((e) => e.name === "AlphaEl");
|
||||
expect(baseAlpha).toBeDefined();
|
||||
expect(extendedAlpha).toBeDefined();
|
||||
// With counter-based ids: base AlphaEl = element-1, extended AlphaEl = element-2 — FAILS.
|
||||
// With hf- stable ids: both = same hf-xxxx — PASSES (R1 target).
|
||||
expect(extendedAlpha?.id).toBe(baseAlpha?.id);
|
||||
});
|
||||
|
||||
// --- Baseline (already pass, must not regress) ---
|
||||
|
||||
it("existing data-hf-id is pinned and becomes the clip id (never re-minted)", () => {
|
||||
const html = `<html><body><div id="stage">
|
||||
<div data-hf-id="hf-anch" data-start="0" data-end="5" data-name="Title"><div>Hi</div></div>
|
||||
</div></body></html>`;
|
||||
const { elements } = parseHtml(html);
|
||||
expect(elements.some((e) => e.id === "hf-anch")).toBe(true);
|
||||
});
|
||||
|
||||
it("ids are deterministic: same input produces same ids on re-parse", () => {
|
||||
const html = `<html><body><div id="stage">
|
||||
<div data-start="0" data-end="5" data-name="A"><div>A</div></div>
|
||||
<div data-start="0" data-end="5" data-name="B"><div>B</div></div>
|
||||
</div></body></html>`;
|
||||
const first = parseHtml(html).elements.map((e) => e.id);
|
||||
const second = parseHtml(html).elements.map((e) => e.id);
|
||||
expect(first).toEqual(second);
|
||||
});
|
||||
|
||||
it("ids are unique within a document", () => {
|
||||
const html = `<html><body><div id="stage">
|
||||
<div data-start="0" data-end="3" data-name="A"><div>A</div></div>
|
||||
<div data-start="1" data-end="4" data-name="B"><div>B</div></div>
|
||||
<div data-start="2" data-end="5" data-name="C"><div>C</div></div>
|
||||
</div></body></html>`;
|
||||
const ids = parseHtml(html).elements.map((e) => e.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it("two elements with identical markup get distinct ids (no content-hash collision)", () => {
|
||||
// Ensures R1's id derivation includes position or a sibling counter,
|
||||
// not just content — two structurally identical elements must not collide.
|
||||
const html = `<html><body><div id="stage">
|
||||
<div data-start="0" data-end="5" data-name="X"><div>Same</div></div>
|
||||
<div data-start="0" data-end="5" data-name="X"><div>Same</div></div>
|
||||
</div></body></html>`;
|
||||
const { elements } = parseHtml(html);
|
||||
const ids = elements.map((e) => e.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it("ids survive a serialize → re-parse round-trip", () => {
|
||||
const html = `<html><body><div id="stage">
|
||||
<div id="my-anchor" data-start="0" data-end="5" data-name="Anchor"><div>Content</div></div>
|
||||
<img src="photo.jpg" data-start="1" data-end="8" data-name="Photo" />
|
||||
</div></body></html>`;
|
||||
const original = parseHtml(html);
|
||||
const reparsed = parseHtml(serialize(original));
|
||||
const origIds = original.elements.map((e) => e.id).sort();
|
||||
const roundIds = reparsed.elements.map((e) => e.id).sort();
|
||||
expect(roundIds).toEqual(origIds);
|
||||
});
|
||||
|
||||
it.todo("sub-composition instances get scoped ids (compositionId/hf-x) — requires SDK session");
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseHTML } from "linkedom";
|
||||
import { checkSubCompositionUsability, type ParsableDocumentLike } from "./subCompositionValidity";
|
||||
|
||||
function parse(html: string): ParsableDocumentLike {
|
||||
return parseHTML(html).document as unknown as ParsableDocumentLike;
|
||||
}
|
||||
|
||||
const VALID_HTML = `<template id="intro-template">
|
||||
<div id="intro" data-composition-id="intro" data-width="1920" data-height="1080">
|
||||
<div class="title">HELLO WORLD</div>
|
||||
</div>
|
||||
</template>`;
|
||||
|
||||
const VALID_HTML_NO_TEMPLATE = `<!doctype html>
|
||||
<html>
|
||||
<head></head>
|
||||
<body>
|
||||
<div data-composition-id="intro" data-width="1920" data-height="1080">
|
||||
<div class="title">HELLO WORLD</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
describe("checkSubCompositionUsability", () => {
|
||||
it("accepts a valid <template>-based sub-composition", () => {
|
||||
expect(checkSubCompositionUsability(VALID_HTML, parse)).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("accepts a valid full-document sub-composition with no <template>", () => {
|
||||
expect(checkSubCompositionUsability(VALID_HTML_NO_TEMPLATE, parse)).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("rejects an empty string without ever calling parseHtml (avoids the linkedom null-deref crash)", () => {
|
||||
let parseCalled = false;
|
||||
const spyParse = (html: string): ParsableDocumentLike => {
|
||||
parseCalled = true;
|
||||
return parse(html);
|
||||
};
|
||||
const result = checkSubCompositionUsability("", spyParse);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.reason).toBe("empty");
|
||||
expect(parseCalled).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects whitespace-only content", () => {
|
||||
const result = checkSubCompositionUsability(" \n\t ", parse);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.reason).toBe("empty");
|
||||
});
|
||||
|
||||
it("rejects null/undefined (file could not be read)", () => {
|
||||
expect(checkSubCompositionUsability(null, parse).ok).toBe(false);
|
||||
expect(checkSubCompositionUsability(undefined, parse).ok).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a valid-but-empty document (parses fine, no body content)", () => {
|
||||
const result = checkSubCompositionUsability(
|
||||
"<!doctype html><html><head></head><body></body></html>",
|
||||
parse,
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.reason).toBe("no-content");
|
||||
});
|
||||
|
||||
it("rejects plain text with no tags — this is the exact input that crashes linkedom's Document.head getter", () => {
|
||||
// linkedom's parseHTML("just some text") returns documentElement === null.
|
||||
// Any caller that touches .head/.body on that document (as linkedom's own
|
||||
// internals do) throws "Cannot destructure property 'firstElementChild'
|
||||
// of 'documentElement' as it is null." checkSubCompositionUsability must
|
||||
// detect this from `documentElement` alone, before touching .head/.body.
|
||||
const result = checkSubCompositionUsability("just some plain text, no tags at all", parse);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.reason).toBe("unparsable");
|
||||
});
|
||||
|
||||
it("rejects a <template> with only whitespace content", () => {
|
||||
const result = checkSubCompositionUsability("<template> \n </template>", parse);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.reason).toBe("no-content");
|
||||
});
|
||||
|
||||
it("rejects non-empty, parseable HTML with no data-composition-id anywhere (e.g. an AI-authored placeholder scene)", () => {
|
||||
const result = checkSubCompositionUsability(
|
||||
"<!doctype html><html><head></head><body><p>TODO: scene content</p></body></html>",
|
||||
parse,
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.reason).toBe("no-composition-root");
|
||||
});
|
||||
|
||||
it("rejects a <template> with content but no data-composition-id element inside", () => {
|
||||
const result = checkSubCompositionUsability(
|
||||
'<template><div class="title">HELLO WORLD</div></template>',
|
||||
parse,
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.reason).toBe("no-composition-root");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Shared "is this sub-composition file usable?" check.
|
||||
*
|
||||
* `data-composition-src` files are authored by AI agents far more often than
|
||||
* by humans clicking a UI. The dominant real-world failure is a scene worker
|
||||
* that dies mid-write (or a step that references a scene before writing it),
|
||||
* leaving an empty or partial `compositions/scene-*.html` on disk. Historically
|
||||
* this surfaced in three different ways depending on which code path touched
|
||||
* the file first:
|
||||
*
|
||||
* 1. A raw crash inside linkedom's `Document.head` getter — destructuring
|
||||
* `firstElementChild` off a `null` `documentElement` — when the file is
|
||||
* empty or contains no parseable markup.
|
||||
* 2. An actionable-but-late `Error` thrown deep inside the render compiler
|
||||
* (see git history: #1364), which aborted the whole render.
|
||||
* 3. A silent skip (see git history: #1678) that drops the scene from the
|
||||
* output with only a `console.warn`, producing a materially broken
|
||||
* video (missing scene, no error surfaced anywhere) with no clear
|
||||
* signal to the caller.
|
||||
*
|
||||
* This module gives every consumer (lint, render pre-flight, the tolerant
|
||||
* inliner) a single, shared definition of "usable" so they can never
|
||||
* disagree about whether a given file would render something. It lives in
|
||||
* `@hyperframes/parsers` (rather than `@hyperframes/core`, where it
|
||||
* originated) because `@hyperframes/lint` needs it too, and `lint` cannot
|
||||
* depend on `core` — `core` already depends on `lint` — so this shared,
|
||||
* dependency-free check lives in the common ancestor package both `core`
|
||||
* and `lint` already depend on.
|
||||
*
|
||||
* `inlineSubCompositions.ts` (in `@hyperframes/core`) intentionally stays
|
||||
* tolerant (skip + continue) for the preview/studio bundling path, where
|
||||
* partial content while iterating is expected. `lint` and the render
|
||||
* pre-flight check (`packages/producer/src/services/htmlCompiler.ts`) use
|
||||
* this helper to fail loudly and name the exact offending file, because a
|
||||
* render that silently drops a scene is strictly worse than a render that
|
||||
* refuses to start.
|
||||
*/
|
||||
|
||||
export type SubCompositionValidityReason =
|
||||
| "empty"
|
||||
| "unparsable"
|
||||
| "no-content"
|
||||
| "no-composition-root";
|
||||
|
||||
export interface SubCompositionValidity {
|
||||
ok: boolean;
|
||||
/** Present when `ok` is false. */
|
||||
reason?: SubCompositionValidityReason;
|
||||
/** Human-readable detail suitable for direct inclusion in an error message. */
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
/** Minimal shape both linkedom's `Document` and `happy-dom`'s satisfy. */
|
||||
export interface ParsableDocumentLike {
|
||||
documentElement: { outerHTML?: string } | null;
|
||||
body?: { innerHTML?: string | null } | null;
|
||||
querySelector(selector: string): { innerHTML?: string | null } | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether `html` (the raw file contents resolved for a
|
||||
* `data-composition-src` reference) is non-empty and parses to a document
|
||||
* that actually contains renderable content.
|
||||
*
|
||||
* Mirrors the content-detection steps in `inlineSubCompositions` exactly
|
||||
* (resolve → parse → find `<template>` or `<body>` content → parse that →
|
||||
* confirm a `[data-composition-id]` root exists in it), so a file that
|
||||
* passes this check is guaranteed to produce non-empty output from the
|
||||
* inliner, and a file that fails it is guaranteed to hit one of the
|
||||
* inliner's `onMissingComposition` branches.
|
||||
*
|
||||
* @param html Raw file contents, or `null`/`undefined` if the file could not
|
||||
* be read (e.g. missing from disk). Callers should distinguish "missing"
|
||||
* from "empty" in their own error message using a separate existence
|
||||
* check — this function only inspects content.
|
||||
* @param parseHtml Parse an HTML string into a document. Pass linkedom's
|
||||
* `parseHTML(html).document` or the core bundler's `parseHTMLContent`.
|
||||
*/
|
||||
export function checkSubCompositionUsability(
|
||||
html: string | null | undefined,
|
||||
parseHtml: (html: string) => ParsableDocumentLike,
|
||||
): SubCompositionValidity {
|
||||
if (html == null || !html.trim()) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "empty",
|
||||
detail: "the file is empty (0 bytes or whitespace-only)",
|
||||
};
|
||||
}
|
||||
|
||||
const compDoc = parseHtml(html);
|
||||
if (!compDoc.documentElement) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "unparsable",
|
||||
detail: "the file's contents could not be parsed as HTML",
|
||||
};
|
||||
}
|
||||
|
||||
// Find content: prefer <template>, fall back to <body> — same precedence
|
||||
// inlineSubCompositions uses when extracting the sub-composition's markup.
|
||||
const contentRoot = compDoc.querySelector("template");
|
||||
const contentHtml = contentRoot ? contentRoot.innerHTML || "" : compDoc.body?.innerHTML || "";
|
||||
if (!contentHtml.trim()) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "no-content",
|
||||
detail: "the file has no <template> or <body> content to render",
|
||||
};
|
||||
}
|
||||
|
||||
const contentDoc = parseHtml(contentHtml);
|
||||
if (!contentDoc.documentElement) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "unparsable",
|
||||
detail: "the file's <template>/<body> contents could not be parsed as HTML",
|
||||
};
|
||||
}
|
||||
|
||||
// The content must contain an actual composition root — the element the
|
||||
// inliner looks for (`contentDoc.querySelector("[data-composition-id]")`)
|
||||
// to know what to inject into the host. Well-formed but marker-free HTML
|
||||
// (e.g. an AI-authored placeholder like `<body><p>TODO</p></body>`) parses
|
||||
// fine and has non-empty content, but has nothing for the inliner to find.
|
||||
if (!contentDoc.querySelector("[data-composition-id]")) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "no-composition-root",
|
||||
detail:
|
||||
"the file's <template>/<body> content has no element with a data-composition-id attribute",
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Shared test utilities for parser test suites (T1, T2, T6…).
|
||||
* Import from here rather than duplicating helpers across test files.
|
||||
*
|
||||
* Not part of the public package exports — consumed only by *.test.ts files.
|
||||
*/
|
||||
import { generateHyperframesHtml } from "@hyperframes/core/generators";
|
||||
import type { ParsedHtml } from "./htmlParser.js";
|
||||
|
||||
export function maxEndTime(elements: ParsedHtml["elements"]): number {
|
||||
if (elements.length === 0) return 0;
|
||||
return Math.max(...elements.map((e) => e.startTime + e.duration));
|
||||
}
|
||||
|
||||
/**
|
||||
* Round-trip serialize helper.
|
||||
* Fixed compositionId prevents Date.now() churn from masking structural instability.
|
||||
* The compositionId generation instability itself is tracked as R1 (stable hf- ids).
|
||||
*/
|
||||
export function serialize(parsed: ParsedHtml): string {
|
||||
return generateHyperframesHtml(parsed.elements, maxEndTime(parsed.elements), {
|
||||
compositionId: "test-comp",
|
||||
resolution: parsed.resolution,
|
||||
styles: parsed.styles ?? undefined,
|
||||
keyframes: parsed.keyframes,
|
||||
stageZoomKeyframes: parsed.stageZoomKeyframes,
|
||||
});
|
||||
}
|
||||
@@ -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<string, CanvasResolution> = {
|
||||
"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<string, string | number | boolean>;
|
||||
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<TimelineElementType, string> = {
|
||||
video: "#ec4899",
|
||||
image: "#3b82f6",
|
||||
text: "#06b6d4",
|
||||
audio: "#10b981",
|
||||
composition: "#f97316",
|
||||
};
|
||||
|
||||
export const DEFAULT_DURATIONS: Record<TimelineElementType, number> = {
|
||||
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, options?: { suppressEvents?: boolean }): 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<KeyframeProperties>;
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export function decodeUrlPathVariants(path: string): string[] {
|
||||
const variants = [path];
|
||||
try {
|
||||
const decoded = decodeURIComponent(path);
|
||||
if (decoded !== path) variants.unshift(decoded);
|
||||
} catch {
|
||||
// Malformed percent sequences may be literal filesystem names.
|
||||
}
|
||||
|
||||
return variants;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { scanVariableUsage } from "./variableUsage.js";
|
||||
|
||||
describe("scanVariableUsage", () => {
|
||||
it("collects ids from direct destructuring with defaults", () => {
|
||||
const scan = scanVariableUsage(`
|
||||
const { title = "Untitled", accent } = __hyperframes.getVariables();
|
||||
document.querySelector("h1").textContent = title;
|
||||
`);
|
||||
expect(scan.usedIds).toEqual(["title", "accent"]);
|
||||
expect(scan.scanIncomplete).toBe(false);
|
||||
});
|
||||
|
||||
it("handles bare getVariables (sub-comp scoped shadow) and window-qualified calls", () => {
|
||||
expect(scanVariableUsage(`const { a } = getVariables();`).usedIds).toEqual(["a"]);
|
||||
expect(scanVariableUsage(`const { b } = window.__hyperframes.getVariables();`).usedIds).toEqual(
|
||||
["b"],
|
||||
);
|
||||
});
|
||||
|
||||
it("collects ids from member access on the call and on an alias", () => {
|
||||
const scan = scanVariableUsage(`
|
||||
const x = __hyperframes.getVariables().headline;
|
||||
const vars = __hyperframes.getVariables();
|
||||
el.style.color = vars.accent;
|
||||
const size = vars["font-size"];
|
||||
const { title } = vars;
|
||||
`);
|
||||
expect(scan.usedIds).toEqual(["headline", "accent", "font-size", "title"]);
|
||||
expect(scan.scanIncomplete).toBe(false);
|
||||
});
|
||||
|
||||
it("collects string-literal destructuring keys", () => {
|
||||
const scan = scanVariableUsage(`const { "kebab-id": kebab } = getVariables();`);
|
||||
expect(scan.usedIds).toEqual(["kebab-id"]);
|
||||
expect(scan.scanIncomplete).toBe(false);
|
||||
});
|
||||
|
||||
it("flags dynamic access as incomplete without losing static ids", () => {
|
||||
const scan = scanVariableUsage(`
|
||||
const vars = getVariables();
|
||||
const known = vars.known;
|
||||
const dynamic = vars[someKey];
|
||||
`);
|
||||
expect(scan.usedIds).toEqual(["known"]);
|
||||
expect(scan.scanIncomplete).toBe(true);
|
||||
});
|
||||
|
||||
it("flags rest spreads, escaping values, and chained aliases", () => {
|
||||
expect(scanVariableUsage(`const { a, ...rest } = getVariables();`).scanIncomplete).toBe(true);
|
||||
expect(scanVariableUsage(`render(getVariables());`).scanIncomplete).toBe(true);
|
||||
expect(
|
||||
scanVariableUsage(`const vars = getVariables(); const v2 = vars; use(v2.x);`).scanIncomplete,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("flags unparseable scripts as incomplete", () => {
|
||||
const scan = scanVariableUsage(`const { = broken`);
|
||||
expect(scan.usedIds).toEqual([]);
|
||||
expect(scan.scanIncomplete).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores unrelated code and same-named object keys", () => {
|
||||
const scan = scanVariableUsage(`
|
||||
const vars = getVariables();
|
||||
const config = { vars: 1, other: vars.real };
|
||||
gsap.timeline({ paused: true });
|
||||
`);
|
||||
expect(scan.usedIds).toEqual(["real"]);
|
||||
expect(scan.scanIncomplete).toBe(false);
|
||||
});
|
||||
|
||||
it("returns empty for scripts that never touch variables", () => {
|
||||
const scan = scanVariableUsage(`gsap.timeline({ paused: true }).to(".x", { opacity: 1 });`);
|
||||
expect(scan.usedIds).toEqual([]);
|
||||
expect(scan.scanIncomplete).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Browser-safe static scan for composition-variable reads in script text.
|
||||
*
|
||||
* Compositions read variables by calling the runtime API — `getVariables()`
|
||||
* bare (sub-comp scoped shadow) or via `__hyperframes.getVariables()` /
|
||||
* `window.__hyperframes.getVariables()` — and there is no DOM-attribute
|
||||
* binding to scan, so "which variables does this composition use" can only be
|
||||
* derived from the scripts. This is a best-effort static analysis: the
|
||||
* patterns agents actually write (destructuring, member access, a single
|
||||
* alias variable) resolve to ids; anything opaque flips `scanIncomplete`
|
||||
* so consumers can present usage as a lower bound instead of a fact.
|
||||
*
|
||||
* AST nodes are handled untyped (same convention as gsapParserAcorn.ts) —
|
||||
* acorn's structural types don't survive acorn-walk's visitor signatures.
|
||||
*/
|
||||
|
||||
import * as acorn from "acorn";
|
||||
import * as acornWalk from "acorn-walk";
|
||||
|
||||
export interface VariableUsageScan {
|
||||
/** Variable ids statically read by the script, in first-seen order. */
|
||||
usedIds: string[];
|
||||
/**
|
||||
* True when the script accesses variables in a way the scan cannot resolve
|
||||
* (computed keys, rest spreads, the values object escaping into a call…) or
|
||||
* when the script fails to parse — usedIds is then a lower bound.
|
||||
*/
|
||||
scanIncomplete: boolean;
|
||||
}
|
||||
|
||||
interface Sink {
|
||||
use(id: string): void;
|
||||
incomplete(): void;
|
||||
}
|
||||
|
||||
// oxlint-disable no-explicit-any -- untyped acorn AST traversal, see header
|
||||
|
||||
function isGetVariablesCallee(callee: any): boolean {
|
||||
if (callee?.type === "Identifier") return callee.name === "getVariables";
|
||||
if (callee?.type === "MemberExpression" && !callee.computed) {
|
||||
return callee.property?.type === "Identifier" && callee.property.name === "getVariables";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Collect ids from an ObjectPattern destructuring of the values object. */
|
||||
// Exhaustive AST-node classification — branchy by nature, same as gsapParserAcorn.
|
||||
// fallow-ignore-next-line complexity
|
||||
function collectFromObjectPattern(pattern: any, out: Sink): void {
|
||||
for (const prop of pattern.properties ?? []) {
|
||||
if (prop?.type === "RestElement") {
|
||||
out.incomplete();
|
||||
continue;
|
||||
}
|
||||
if (prop?.type !== "Property") continue;
|
||||
if (prop.computed === true) {
|
||||
out.incomplete();
|
||||
} else if (prop.key?.type === "Identifier") {
|
||||
out.use(String(prop.key.name));
|
||||
} else if (prop.key?.type === "Literal" && typeof prop.key.value === "string") {
|
||||
out.use(prop.key.value);
|
||||
} else {
|
||||
out.incomplete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Collect an id from a MemberExpression reading the values object. */
|
||||
function collectFromMemberAccess(member: any, out: Sink): void {
|
||||
if (member.computed !== true && member.property?.type === "Identifier") {
|
||||
out.use(String(member.property.name));
|
||||
} else if (
|
||||
member.computed === true &&
|
||||
member.property?.type === "Literal" &&
|
||||
typeof member.property.value === "string"
|
||||
) {
|
||||
out.use(member.property.value);
|
||||
} else {
|
||||
out.incomplete();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify one read of the values object (a getVariables() call result or an
|
||||
* alias holding it) by its immediate syntactic context. Returns the alias
|
||||
* name when the value is bound to a plain variable (`const vars = …`).
|
||||
*/
|
||||
// fallow-ignore-next-line complexity
|
||||
function classifyValueRead(parent: any, valueNode: any, out: Sink): string | null {
|
||||
if (!parent || parent.type === "ExpressionStatement") {
|
||||
// Bare statement — value unused, nothing read.
|
||||
return null;
|
||||
}
|
||||
if (parent.type === "MemberExpression" && parent.object === valueNode) {
|
||||
collectFromMemberAccess(parent, out);
|
||||
return null;
|
||||
}
|
||||
if (parent.type === "VariableDeclarator" && parent.init === valueNode) {
|
||||
if (parent.id?.type === "ObjectPattern") {
|
||||
collectFromObjectPattern(parent.id, out);
|
||||
return null;
|
||||
}
|
||||
if (parent.id?.type === "Identifier") return String(parent.id.name);
|
||||
out.incomplete();
|
||||
return null;
|
||||
}
|
||||
// The values object escapes (argument, return, spread, assignment…) —
|
||||
// reads beyond this point are invisible to the scan.
|
||||
out.incomplete();
|
||||
return null;
|
||||
}
|
||||
|
||||
export function scanVariableUsage(scriptText: string): VariableUsageScan {
|
||||
const usedIds: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
let scanIncomplete = false;
|
||||
|
||||
const sink: Sink = {
|
||||
use(id: string) {
|
||||
if (!seen.has(id)) {
|
||||
seen.add(id);
|
||||
usedIds.push(id);
|
||||
}
|
||||
},
|
||||
incomplete() {
|
||||
scanIncomplete = true;
|
||||
},
|
||||
};
|
||||
|
||||
let ast: any;
|
||||
try {
|
||||
ast = acorn.parse(scriptText, { ecmaVersion: "latest", sourceType: "script" });
|
||||
} catch {
|
||||
return { usedIds: [], scanIncomplete: true };
|
||||
}
|
||||
|
||||
const aliases = new Set<string>();
|
||||
|
||||
// Pass 1: classify every getVariables() call by its parent context.
|
||||
acornWalk.ancestor(ast, {
|
||||
CallExpression(node: any, _: unknown, ancestors: any[]) {
|
||||
if (!isGetVariablesCallee(node.callee)) return;
|
||||
const parent = ancestors[ancestors.length - 2];
|
||||
const alias = classifyValueRead(parent, node, sink);
|
||||
if (alias) aliases.add(alias);
|
||||
},
|
||||
} as any);
|
||||
|
||||
// Pass 2: classify every reference to an alias of the values object.
|
||||
// Scope-naive by design: an unrelated same-named identifier can only make
|
||||
// the scan report extra ids or flip scanIncomplete, never miss a read.
|
||||
if (aliases.size > 0) {
|
||||
acornWalk.ancestor(ast, {
|
||||
// fallow-ignore-next-line complexity
|
||||
Identifier(node: any, _: unknown, ancestors: any[]) {
|
||||
if (!aliases.has(String(node.name))) return;
|
||||
const parent = ancestors[ancestors.length - 2];
|
||||
if (!parent) return;
|
||||
// Skip the declarator that introduced the alias and property-position
|
||||
// identifiers that merely share the name.
|
||||
if (parent.type === "VariableDeclarator" && parent.id === node) return;
|
||||
if (parent.type === "MemberExpression" && parent.property === node) return;
|
||||
if (parent.type === "Property" && parent.key === node && parent.computed !== true) return;
|
||||
// Chained aliases (const v2 = vars) are not followed — flag instead
|
||||
// of silently missing reads through the second name.
|
||||
if (classifyValueRead(parent, node, sink)) sink.incomplete();
|
||||
},
|
||||
} as any);
|
||||
}
|
||||
|
||||
return { usedIds, scanIncomplete };
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
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",
|
||||
slideshow: "src/slideshow/index.ts",
|
||||
assets: "src/assets.ts",
|
||||
composition: "src/composition.ts",
|
||||
subCompositionValidity: "src/subCompositionValidity.ts",
|
||||
},
|
||||
format: ["esm"],
|
||||
outDir: "dist",
|
||||
target: "node22",
|
||||
platform: "node",
|
||||
bundle: true,
|
||||
splitting: false,
|
||||
sourcemap: true,
|
||||
clean: true,
|
||||
dts: true,
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["src/**/*.test.ts"],
|
||||
environment: "jsdom",
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user