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

This commit is contained in:
wehub-resource-sync
2026-07-13 12:58:35 +08:00
commit 85453da49f
4031 changed files with 710987 additions and 0 deletions
+171
View File
@@ -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;
+45
View File
@@ -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);
}
+4
View File
@@ -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";
+18
View File
@@ -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 [];
}
}
+129
View File
@@ -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];
}
+124
View File
@@ -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)",
];
+171
View File
@@ -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"]);
});
});
+584
View File
@@ -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
+47
View File
@@ -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";
+604
View File
@@ -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} }`;
}
+62
View File
@@ -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);
});
});
+143
View File
@@ -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);
});
});
+198
View File
@@ -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
});
});
+199
View File
@@ -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/);
});
});
+822
View File
@@ -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();
});
});
+885
View File
@@ -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;
}
+21
View File
@@ -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")`);
});
});
+115
View File
@@ -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 || ""})`;
});
}
+3
View File
@@ -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 };
}
+15
View File
@@ -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
}
+89
View File
@@ -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);
});
});
+88
View File
@@ -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(" ")}`;
}
+118
View File
@@ -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 };
}
+28
View File
@@ -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,
});
}
+467
View File
@@ -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,
};
}
+14
View File
@@ -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;
}
+11
View File
@@ -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);
});
});
+172
View File
@@ -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 };
}
+18
View File
@@ -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"]
}
+27
View File
@@ -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,
});
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/**/*.test.ts"],
environment: "jsdom",
},
});