Merge branch 'animejs-u9' into worktree-feat-animejs-first-party

This commit is contained in:
Miguel Angel Simon Sierra
2026-07-08 17:30:40 -04:00
37 changed files with 6868 additions and 1 deletions
+1 -1
View File
@@ -32,7 +32,7 @@
"player:perf": "bun run --filter @hyperframes/player perf",
"format:check": "oxfmt --check .",
"knip": "knip",
"test:scripts": "node --import tsx --test scripts/validate-release-channel.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/verify-packed-manifests.test.mjs",
"test:scripts": "node --import tsx --test scripts/validate-release-channel.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/verify-packed-manifests.test.mjs scripts/gsap-to-anime/*.test.ts",
"test:skills": "node --test 'skills/**/*.test.mjs'",
"generate:previews": "tsx scripts/generate-template-previews.ts",
"generate:catalog-previews": "tsx scripts/generate-catalog-previews.ts",
@@ -0,0 +1,15 @@
<!doctype html>
<html>
<body>
<script>
const tl = anime.createTimeline({ autoplay: false });
tl.add("#native", { translateX: 10, duration: 1000, ease: "outCubic" }, 0);
tl.add("#back", { scale: 1.2, duration: 1000, ease: "outBack(1.2)" }, 0);
tl.add("#elastic", { translateY: 20, duration: 1000, ease: "outElastic(1, .4)" }, 0);
tl.add("#expo", { opacity: 0, duration: 1000, ease: "outExpo" }, 0);
tl.add("#steps", { opacity: 1, duration: 1000, ease: "steps(4)" }, 0);
tl.add("#custom", { translateX: 20, duration: 1000, ease: "M0,0 L1,1" }, 0);
hyperframesAnime.register("eases", tl);
</script>
</body>
</html>
@@ -0,0 +1,16 @@
<!doctype html>
<html>
<body>
<script>
const tl = gsap.timeline({ paused: true });
tl.to("#native", { x: 10, duration: 1, ease: "power2.out" }, 0);
tl.to("#back", { scale: 1.2, duration: 1, ease: "back.out(1.2)" }, 0);
tl.to("#elastic", { y: 20, duration: 1, ease: "elastic.out(1, 0.4)" }, 0);
tl.to("#expo", { opacity: 0, duration: 1, ease: "expo.out" }, 0);
tl.to("#steps", { opacity: 1, duration: 1, ease: "steps(4)" }, 0);
tl.to("#custom", { x: 20, duration: 1, ease: "M0,0 L1,1" }, 0);
window.__timelines = window.__timelines || {};
window.__timelines["eases"] = tl;
</script>
</body>
</html>
@@ -0,0 +1,16 @@
<!doctype html>
<html>
<body>
<div id="card"></div>
<script>
const tl = anime.createTimeline({ autoplay: false });
tl.add("#card", { opacity: 0, scale: 0.8, duration: 0 }, 0);
tl.add(
"#card",
{ translateX: [-50, 100], opacity: [0, 1], duration: 750, ease: "outBack(1.5)" },
250,
);
hyperframesAnime.register("card", tl);
</script>
</body>
</html>
@@ -0,0 +1,18 @@
<!doctype html>
<html>
<body>
<div id="card"></div>
<script>
const tl = gsap.timeline({ paused: true });
tl.set("#card", { opacity: 0, scale: 0.8 }, 0);
tl.fromTo(
"#card",
{ x: -50, opacity: 0 },
{ x: 100, opacity: 1, duration: 0.75, ease: "back.out(1.5)" },
0.25,
);
window.__timelines = window.__timelines || {};
window.__timelines["card"] = tl;
</script>
</body>
</html>
@@ -0,0 +1,13 @@
<!doctype html>
<html>
<body>
<div id="card"></div>
<script>
(function () {
const tl = anime.createTimeline({ autoplay: false });
tl.add("#card", { opacity: 1, duration: 500 }, 0);
hyperframesAnime.register("iife-card", tl);
})();
</script>
</body>
</html>
@@ -0,0 +1,14 @@
<!doctype html>
<html>
<body>
<div id="card"></div>
<script>
(function () {
const tl = gsap.timeline({ paused: true });
tl.to("#card", { opacity: 1, duration: 0.5 }, 0);
window.__timelines = window.__timelines || {};
window.__timelines["iife-card"] = tl;
})();
</script>
</body>
</html>
@@ -0,0 +1,23 @@
<!doctype html>
<html>
<body>
<script>
const tl = gsap.timeline({ paused: true });
tl.to(
"#dot",
{
duration: 1,
motionPath: {
path: [
{ x: 0, y: 0 },
{ x: 100, y: 50 },
],
},
},
0,
);
window.__timelines = window.__timelines || {};
window.__timelines["main"] = tl;
</script>
</body>
</html>
@@ -0,0 +1,23 @@
<!doctype html>
<html>
<body>
<div id="count"></div>
<script>
const counter = { val: 0 };
const tl = gsap.timeline({ paused: true });
tl.to(
counter,
{
val: 100,
duration: 1,
onUpdate() {
document.querySelector("#count").textContent = String(counter.val);
},
},
0,
);
window.__timelines = window.__timelines || {};
window.__timelines["counter"] = tl;
</script>
</body>
</html>
@@ -0,0 +1,13 @@
<!doctype html>
<html>
<body>
<svg>
<path id="path"></path>
</svg>
<script>
const tl = anime.createTimeline({ autoplay: false });
tl.add("#path", { strokeDashoffset: 0, x2: 120, "stroke-width": 4, duration: 1000 }, 0);
hyperframesAnime.register("svg", tl);
</script>
</body>
</html>
@@ -0,0 +1,14 @@
<!doctype html>
<html>
<body>
<svg>
<path id="path"></path>
</svg>
<script>
const tl = gsap.timeline({ paused: true });
tl.to("#path", { strokeDashoffset: 0, attr: { x2: 120, "stroke-width": 4 }, duration: 1 }, 0);
window.__timelines = window.__timelines || {};
window.__timelines["svg"] = tl;
</script>
</body>
</html>
@@ -0,0 +1,12 @@
<!doctype html>
<html>
<body>
<script>
const tl = anime.createTimeline({ autoplay: false });
tl.add("#a", { translateX: 100, duration: 1000 }, 500);
tl.add("#b", { translateY: 50, duration: 500 }, 500);
tl.add("#c", { opacity: 1, duration: 250 }, 1750);
hyperframesAnime.register("main", tl, { labels: { intro: 0.5 } });
</script>
</body>
</html>
@@ -0,0 +1,14 @@
<!doctype html>
<html>
<body>
<script>
const tl = gsap.timeline({ paused: true });
tl.addLabel("intro", 0.5);
tl.to("#a", { x: 100, duration: 1 }, "intro");
tl.to("#b", { y: 50, duration: 0.5 }, "<");
tl.to("#c", { opacity: 1, duration: 0.25 }, "+=0.25");
window.__timelines = window.__timelines || {};
window.__timelines["main"] = tl;
</script>
</body>
</html>
@@ -0,0 +1,10 @@
<!doctype html>
<html>
<body>
<script>
const tl = anime.createTimeline({ autoplay: false });
tl.add("#pulse", { scale: 1.25, duration: 400, loop: 3, alternate: true }, 0);
hyperframesAnime.register("pulse", tl);
</script>
</body>
</html>
@@ -0,0 +1,11 @@
<!doctype html>
<html>
<body>
<script>
const tl = gsap.timeline({ paused: true });
tl.to("#pulse", { scale: 1.25, duration: 0.4, repeat: 2, yoyo: true }, 0);
window.__timelines = window.__timelines || {};
window.__timelines["pulse"] = tl;
</script>
</body>
</html>
@@ -0,0 +1,11 @@
<!doctype html>
<html>
<body>
<div id="box"></div>
<script>
const tl = anime.createTimeline({ autoplay: false });
tl.add("#box", { translateX: 120, opacity: 0.5, duration: 1000, ease: "outCubic" }, 0);
hyperframesAnime.register("main", tl);
</script>
</body>
</html>
@@ -0,0 +1,12 @@
<!doctype html>
<html>
<body>
<div id="box"></div>
<script>
const tl = gsap.timeline({ paused: true });
tl.to("#box", { x: 120, opacity: 0.5, duration: 1, ease: "power2.out" }, 0);
window.__timelines = window.__timelines || {};
window.__timelines["main"] = tl;
</script>
</body>
</html>
@@ -0,0 +1,13 @@
<!doctype html>
<html>
<body>
<script>
const split = new SplitText("#headline", { type: "chars" });
const tl = gsap.timeline({ paused: true });
tl.to("#box", { x: 100, duration: 1 }, 0);
tl.to(split.chars, { opacity: 1, stagger: 0.05, duration: 0.2 }, 0);
window.__timelines = window.__timelines || {};
window.__timelines["main"] = tl;
</script>
</body>
</html>
@@ -0,0 +1,20 @@
<!doctype html>
<html>
<body>
<script>
const cells = [...document.querySelectorAll(".cell")];
const tl = anime.createTimeline({ autoplay: false });
tl.add(
".cell",
{
opacity: 1,
scale: 1,
duration: 600,
delay: anime.stagger(100, { grid: [2, 2], from: "center", axis: "x" }),
},
0,
);
hyperframesAnime.register("grid", tl);
</script>
</body>
</html>
@@ -0,0 +1,21 @@
<!doctype html>
<html>
<body>
<script>
const cells = gsap.utils.toArray(".cell");
const tl = gsap.timeline({ paused: true });
tl.to(
cells,
{
opacity: 1,
scale: 1,
duration: 0.6,
stagger: { each: 0.1, grid: [2, 2], from: "center", axis: "x" },
},
0,
);
window.__timelines = window.__timelines || {};
window.__timelines["grid"] = tl;
</script>
</body>
</html>
@@ -0,0 +1,14 @@
<!doctype html>
<html>
<body>
<div id="box"></div>
<script>
const scene = { add() {} };
const slab = {};
scene.add(slab);
const tl = anime.createTimeline({ autoplay: false });
tl.add("#box", { opacity: 1, duration: 400 }, 0);
hyperframesAnime.register("unrelated-add", tl);
</script>
</body>
</html>
@@ -0,0 +1,15 @@
<!doctype html>
<html>
<body>
<div id="box"></div>
<script>
const scene = { add() {} };
const slab = {};
scene.add(slab);
const tl = gsap.timeline({ paused: true });
tl.to("#box", { opacity: 1, duration: 0.4 }, 0);
window.__timelines = window.__timelines || {};
window.__timelines["unrelated-add"] = tl;
</script>
</body>
</html>
@@ -0,0 +1,12 @@
<!doctype html>
<html>
<body>
<div id="card"></div>
<script>
const observerConfig = { delay: 120, onUpdate: "local-option" };
const tl = anime.createTimeline({ autoplay: false });
tl.add("#card", { opacity: 1, duration: 500 }, 0);
hyperframesAnime.register("callback-config", tl);
</script>
</body>
</html>
@@ -0,0 +1,13 @@
<!doctype html>
<html>
<body>
<div id="card"></div>
<script>
const observerConfig = { delay: 120, onUpdate: "local-option" };
const tl = gsap.timeline({ paused: true });
tl.to("#card", { opacity: 1, duration: 0.5 }, 0);
window.__timelines = window.__timelines || {};
window.__timelines["callback-config"] = tl;
</script>
</body>
</html>
+117
View File
@@ -0,0 +1,117 @@
export interface AttrEntry {
key: string;
value: number | string;
}
export function parseAttrWrapper(value: unknown): AttrEntry[] | null {
const raw = rawText(value);
if (!raw?.startsWith("{") || !raw.endsWith("}")) return null;
const body = raw.slice(1, -1).trim();
if (body.length === 0) return [];
const entries: AttrEntry[] = [];
for (const part of splitTopLevel(body, ",")) {
const property = splitProperty(part);
if (property === null) return null;
const key = parseKey(property.key.trim());
const parsedValue = parseValue(property.value.trim());
if (key === null || parsedValue === null) return null;
entries.push({ key, value: parsedValue });
}
return entries;
}
function splitProperty(source: string): { key: string; value: string } | null {
const colon = findTopLevelColon(source);
if (colon === null) return null;
return {
key: source.slice(0, colon),
value: source.slice(colon + 1),
};
}
function splitTopLevel(source: string, separator: string): string[] {
const parts: string[] = [];
let start = 0;
let depth = 0;
let quote: string | null = null;
let escaped = false;
for (let index = 0; index < source.length; index += 1) {
const char = source[index] ?? "";
if (quote !== null) {
if (escaped) escaped = false;
else if (char === "\\") escaped = true;
else if (char === quote) quote = null;
continue;
}
if (char === "'" || char === '"' || char === "`") {
quote = char;
continue;
}
if (char === "{" || char === "[" || char === "(") depth += 1;
if (char === "}" || char === "]" || char === ")") depth -= 1;
if (char === separator && depth === 0) {
parts.push(source.slice(start, index).trim());
start = index + 1;
}
}
parts.push(source.slice(start).trim());
return parts.filter((part) => part.length > 0);
}
function findTopLevelColon(source: string): number | null {
let depth = 0;
let quote: string | null = null;
let escaped = false;
for (let index = 0; index < source.length; index += 1) {
const char = source[index] ?? "";
if (quote !== null) {
if (escaped) escaped = false;
else if (char === "\\") escaped = true;
else if (char === quote) quote = null;
continue;
}
if (char === "'" || char === '"' || char === "`") {
quote = char;
continue;
}
if (char === "{" || char === "[" || char === "(") depth += 1;
if (char === "}" || char === "]" || char === ")") depth -= 1;
if (char === ":" && depth === 0) return index;
}
return null;
}
function parseKey(value: string): string | null {
if (/^[A-Za-z_$][\w$]*$/.test(value)) return value;
return parseStringLiteral(value);
}
function parseValue(value: string): number | string | null {
const numeric = Number(value);
if (value.length > 0 && Number.isFinite(numeric)) return numeric;
return parseStringLiteral(value);
}
function parseStringLiteral(value: string): string | null {
const quote = value[0];
if ((quote !== "'" && quote !== '"') || value[value.length - 1] !== quote) return null;
if (quote === '"') {
try {
const parsed: unknown = JSON.parse(value);
return typeof parsed === "string" ? parsed : null;
} catch {
return null;
}
}
return value.slice(1, -1).replace(/\\'/g, "'").replace(/\\\\/g, "\\");
}
function rawText(value: unknown): string | null {
if (typeof value === "string")
return value.startsWith("__raw:") ? value.slice(6).trim() : value.trim();
if (typeof value === "number" || typeof value === "boolean") return String(value);
return null;
}
+364
View File
@@ -0,0 +1,364 @@
import { resolveEase } from "@hyperframes/core";
import type { GsapAnimation, ParsedGsap } from "@hyperframes/parsers";
import { parseAttrWrapper } from "./attr.ts";
import { addUniqueNote, note, statusFor } from "./notes.ts";
import { parseRegistrationPostamble } from "./registration.ts";
import type { ClassificationNote, CodemodClassification } from "./types.ts";
const ALLOWED_EXTRAS = new Set(["stagger", "repeat", "yoyo"]);
const PLUGIN_NAMES = [
"ScrollTrigger",
"Draggable",
"Flip",
"TextPlugin",
"DrawSVGPlugin",
"MorphSVGPlugin",
"Physics2DPlugin",
"CustomWiggle",
];
export function classifyGsapScript(script: string, parsed: ParsedGsap): CodemodClassification {
const reasons: ClassificationNote[] = [];
const warnings: ClassificationNote[] = [];
addRawSourceReasons(script, parsed, reasons);
addParsedReasons(parsed, reasons);
addAnimationReasons(parsed.animations, reasons, warnings);
if (parseRegistrationPostamble(parsed.postamble, parsed.timelineVar) === null) {
addUniqueNote(
reasons,
note("unrecognized-postamble", "postamble is not only timeline registration"),
);
}
return {
status: statusFor(reasons, warnings),
reasons,
warnings,
parsed,
};
}
function addRawSourceReasons(
script: string,
parsed: ParsedGsap,
reasons: ClassificationNote[],
): void {
if (/SplitText/i.test(script) || hasHandRolledTextSplit(script)) {
addUniqueNote(
reasons,
note("splitText", "SplitText or hand-rolled text splitting feeds the timeline"),
);
}
if (/CustomEase\.create\s*\(/.test(script)) {
addUniqueNote(
reasons,
note("customEase-beyond-shim", "CustomEase registrations are outside the inline path shim"),
);
}
if (/MotionPathPlugin|motionPath\s*:/.test(script)) {
addUniqueNote(reasons, note("motionPath", "motionPath requires manual conversion"));
}
if (hasAdvancedGsapUtils(script)) {
addUniqueNote(reasons, note("gsap-utils-advanced", "gsap.utils usage beyond toArray"));
}
if (hasTimelineControlCall(script, parsed.timelineVar)) {
addUniqueNote(
reasons,
note("computed-timeline", "timeline call/add/addPause is not rewritten safely"),
);
}
const tweenBodies = gsapTweenArgumentSources(script, parsed.timelineVar);
if (tweenBodies.some(hasTweenCallback)) {
addUniqueNote(
reasons,
note("computed-timeline", "timeline callback properties need manual review"),
);
}
if (tweenBodies.some((body) => /\bdelay\s*:/.test(body))) {
addUniqueNote(
reasons,
note("computed-timeline", "GSAP delay is not represented in parsed placement data"),
);
}
addPluginReasons(script, reasons);
}
function hasTimelineControlCall(script: string, timelineVar: string): boolean {
const escapedVar = escapeRegExp(timelineVar);
return new RegExp(`\\b${escapedVar}\\s*\\.\\s*(?:call|add|addPause)\\s*\\(`).test(script);
}
function gsapTweenArgumentSources(script: string, timelineVar: string): string[] {
const escapedVar = escapeRegExp(timelineVar);
const calls = new RegExp(
`(?:\\b${escapedVar}\\s*\\.\\s*(?:fromTo|from|to|set)|\\bgsap\\s*\\.\\s*set)\\s*\\(`,
"g",
);
const bodies: string[] = [];
for (const match of script.matchAll(calls)) {
const start = match.index ?? 0;
const open = script.indexOf("(", start);
const close = findMatchingParen(script, open);
if (close !== null) bodies.push(script.slice(open + 1, close));
}
return bodies;
}
function findMatchingParen(source: string, open: number): number | null {
if (open < 0) return null;
let depth = 0;
let state: "code" | "lineComment" | "blockComment" | "string" = "code";
let quote = "";
let escaped = false;
for (let index = open; index < source.length; index += 1) {
const char = source[index] ?? "";
const next = source[index + 1] ?? "";
const stateResult = updateScanState(state, char, next, quote, escaped);
state = stateResult.state;
quote = stateResult.quote;
escaped = stateResult.escaped;
if (stateResult.skipNext) index += 1;
if (state !== "code") continue;
if (char === "(") depth += 1;
if (char === ")") {
depth -= 1;
if (depth === 0) return index;
}
}
return null;
}
function updateScanState(
state: "code" | "lineComment" | "blockComment" | "string",
char: string,
next: string,
quote: string,
escaped: boolean,
): {
state: "code" | "lineComment" | "blockComment" | "string";
quote: string;
escaped: boolean;
skipNext: boolean;
} {
if (state === "lineComment") {
return char === "\n" || char === "\r"
? { state: "code", quote, escaped: false, skipNext: false }
: { state, quote, escaped: false, skipNext: false };
}
if (state === "blockComment") {
return char === "*" && next === "/"
? { state: "code", quote, escaped: false, skipNext: true }
: { state, quote, escaped: false, skipNext: false };
}
if (state === "string") {
if (escaped) return { state, quote, escaped: false, skipNext: false };
if (char === "\\") return { state, quote, escaped: true, skipNext: false };
if (char === quote) return { state: "code", quote: "", escaped: false, skipNext: false };
return { state, quote, escaped: false, skipNext: false };
}
if (char === "/" && next === "/")
return { state: "lineComment", quote, escaped: false, skipNext: true };
if (char === "/" && next === "*")
return { state: "blockComment", quote, escaped: false, skipNext: true };
if (char === "'" || char === '"' || char === "`")
return { state: "string", quote: char, escaped: false, skipNext: false };
return { state, quote, escaped: false, skipNext: false };
}
function hasTweenCallback(source: string): boolean {
return /\b(onComplete|onStart|onUpdate|onRepeat|onReverseComplete)\s*(?::|\()/.test(source);
}
function hasHandRolledTextSplit(script: string): boolean {
return (
/\.split\s*\(\s*["'](?:\s?|)["']\s*\)/.test(script) &&
/chars|words|createElement|innerHTML/.test(script)
);
}
function hasAdvancedGsapUtils(script: string): boolean {
for (const match of script.matchAll(/gsap\.utils\.([A-Za-z_$][\w$]*)/g)) {
if (match[1] !== "toArray") return true;
}
return false;
}
function addPluginReasons(script: string, reasons: ClassificationNote[]): void {
if (/gsap\.registerPlugin\s*\([^)]*CustomEase/.test(script)) {
addUniqueNote(
reasons,
note("customEase-beyond-shim", "registered CustomEase name needs manual review"),
);
}
if (/gsap\.registerPlugin\s*\([^)]*MotionPathPlugin/.test(script)) {
addUniqueNote(reasons, note("motionPath", "registered MotionPathPlugin needs manual review"));
}
for (const plugin of PLUGIN_NAMES) {
if (new RegExp(`\\b${plugin}\\b`).test(script)) {
addUniqueNote(
reasons,
note(`plugin:${plugin}`, `${plugin} plugin usage needs manual review`),
);
}
}
}
function addParsedReasons(parsed: ParsedGsap, reasons: ClassificationNote[]): void {
if (!/^[A-Za-z_$][\w$]*$/.test(parsed.timelineVar)) {
addUniqueNote(reasons, note("computed-timeline", "timeline is not a simple identifier"));
}
if (parsed.multipleTimelines || parsed.unsupportedTimelinePattern) {
addUniqueNote(reasons, note("computed-timeline", "multiple or unsupported timelines"));
}
}
function addAnimationReasons(
animations: GsapAnimation[],
reasons: ClassificationNote[],
warnings: ClassificationNote[],
): void {
for (const animation of animations) {
if (animation.arcPath)
addUniqueNote(reasons, note("motionPath", "motionPath requires manual conversion"));
if (
(animation.hasUnresolvedKeyframes || animation.hasUnresolvedSelector) &&
!hasReason(reasons, "splitText")
) {
addUniqueNote(reasons, note("computed-timeline", "unresolved selector or keyframes"));
}
if (animation.keyframes && !animation.extras?.stagger && !animation.arcPath) {
addUniqueNote(reasons, note("keyframes", "native GSAP keyframes need manual review"));
}
if (animation.resolvedStart === undefined) {
addUniqueNote(reasons, note("computed-timeline", "animation start could not be resolved"));
}
if (animation.provenance && animation.provenance.kind !== "literal") {
addUniqueNote(reasons, note("computed-timeline", "computed timeline construction"));
}
if (isNonSelectorTarget(animation.targetSelector)) {
addUniqueNote(
reasons,
note("non-selector-target", "tween target is not a CSS selector string"),
);
}
addPropertyReasons(animation, reasons, warnings);
addExtraReasons(animation, reasons, warnings);
addEaseReasons(animation, reasons, warnings);
}
}
function hasReason(reasons: ClassificationNote[], code: string): boolean {
return reasons.some((reason) => reason.code === code);
}
function addPropertyReasons(
animation: GsapAnimation,
reasons: ClassificationNote[],
warnings: ClassificationNote[],
): void {
for (const [key, value] of propertyEntries(animation)) {
if (key === "attr" && parseAttrWrapper(value) === null) {
addUniqueNote(reasons, note("attr-wrapper", "GSAP attr wrapper is not statically literal"));
}
if (key === "autoAlpha") {
addUniqueNote(
warnings,
note("autoAlpha-expansion", "autoAlpha expands to opacity and visibility"),
);
}
if (key === "transformPerspective") {
addUniqueNote(
warnings,
note("transformPerspective-perspective", "transformPerspective maps to CSS perspective"),
);
}
}
}
function propertyEntries(animation: GsapAnimation): Array<[string, number | string]> {
return [
...Object.entries(animation.properties),
...Object.entries(animation.fromProperties ?? {}),
];
}
function isNonSelectorTarget(selector: string): boolean {
return selector === "dwell/hold" || selector.startsWith("proxy ");
}
function addExtraReasons(
animation: GsapAnimation,
reasons: ClassificationNote[],
warnings: ClassificationNote[],
): void {
for (const key of Object.keys(animation.extras ?? {})) {
if (!ALLOWED_EXTRAS.has(key)) {
addUniqueNote(reasons, note("unsupported-extra", `Unsupported GSAP extra: ${key}`));
}
}
const repeat = animation.extras?.repeat;
if (isInfiniteRepeat(repeat)) {
addUniqueNote(
warnings,
note("infinite-repeat", "repeat:-1 or repeat:Infinity creates an infinite loop"),
);
}
}
function isInfiniteRepeat(value: unknown): boolean {
const raw = rawText(value);
return raw === "-1" || raw === "Infinity";
}
function addEaseReasons(
animation: GsapAnimation,
reasons: ClassificationNote[],
warnings: ClassificationNote[],
): void {
const eases = animationEases(animation);
for (const ease of eases) {
if (isEaseShimDivergence(ease)) {
addUniqueNote(warnings, note("ease-shim-divergence", "ease shim divergence"));
}
const resolved = resolveEase(ease);
if (resolved.warning) {
addUniqueNote(
reasons,
note(
"customEase-beyond-shim",
`Unrecognized or fallback ease needs manual review: ${ease}`,
),
);
}
}
}
function animationEases(animation: GsapAnimation): string[] {
const eases: string[] = [];
if (animation.ease) eases.push(animation.ease);
const keyframes = animation.keyframes;
if (keyframes?.ease) eases.push(keyframes.ease);
if (keyframes?.easeEach) eases.push(keyframes.easeEach);
for (const keyframe of keyframes?.keyframes ?? []) {
if (keyframe.ease) eases.push(keyframe.ease);
}
return eases;
}
function isEaseShimDivergence(ease: string): boolean {
const compact = ease.trim().replace(/\s+/g, "").toLowerCase();
return compact.startsWith("expo.") || /^steps\(\d+\)$/.test(compact);
}
function rawText(value: unknown): string | null {
if (typeof value === "string")
return value.startsWith("__raw:") ? value.slice(6).trim() : value.trim();
if (typeof value === "number" || typeof value === "boolean") return String(value);
return null;
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env tsx
import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import {
assertTotalsReconcile,
renderMarkdownReport,
scanRegistryTree,
transformHtml,
} from "./index.ts";
interface Options {
target: string;
dryRun: boolean;
write: boolean;
reportJson: string;
reportMd: string;
}
function main(): void {
const options = parseArgs(process.argv.slice(2));
const target = resolve(options.target);
if (!existsSync(target)) fail(`Path not found: ${target}`);
if (statSync(target).isDirectory()) {
runDirectory(target, options);
return;
}
runFile(target, options);
}
function parseArgs(args: string[]): Options {
let target = "";
let dryRun = false;
let write = false;
let reportJson = "scripts/gsap-to-anime/dry-run-report.json";
let reportMd = "scripts/gsap-to-anime/dry-run-report.md";
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === "--dry-run") dryRun = true;
else if (arg === "--write") write = true;
else if (arg === "--report-json") reportJson = readValue(args, (index += 1), arg);
else if (arg === "--report-md") reportMd = readValue(args, (index += 1), arg);
else if (arg && !arg.startsWith("--") && target === "") target = arg;
else fail(`Unknown argument: ${arg ?? ""}`);
}
if (!target)
fail(
"Usage: node --import tsx scripts/gsap-to-anime/cli.ts <file-or-registry-dir> [--dry-run] [--write]",
);
return { target, dryRun, write, reportJson, reportMd };
}
function readValue(args: string[], index: number, flag: string): string {
const value = args[index];
if (!value) fail(`${flag} requires a value`);
return value;
}
function runFile(path: string, options: Options): void {
const html = readFileSync(path, "utf-8");
const result = transformHtml(html);
console.log(JSON.stringify(result.classification, null, 2));
if (options.write && !options.dryRun && result.changed) {
writeFileSync(path, result.html, "utf-8");
}
}
function runDirectory(path: string, options: Options): void {
if (!options.dryRun) fail("Directory mode is dry-run only for this codemod unit");
const report = scanRegistryTree(path);
assertTotalsReconcile(report);
writeFileSync(resolve(options.reportJson), `${JSON.stringify(report, null, 2)}\n`, "utf-8");
writeFileSync(resolve(options.reportMd), renderMarkdownReport(report), "utf-8");
console.log(JSON.stringify(report.totals));
}
function fail(message: string): never {
console.error(message);
process.exit(1);
}
main();
+339
View File
@@ -0,0 +1,339 @@
import { resolveEase } from "@hyperframes/core";
import type { GsapAnimation, ParsedGsap } from "@hyperframes/parsers";
import { extractGsapLabels } from "@hyperframes/parsers/gsap-parser-acorn";
import { parseAttrWrapper, type AttrEntry } from "./attr.ts";
import { parseRegistrationPostamble, stripLegacyRegistrySetup } from "./registration.ts";
interface AnimeProperty {
key: string;
code: string;
}
const PROP_RENAMES = new Map([
["x", "translateX"],
["y", "translateY"],
["z", "translateZ"],
["rotation", "rotate"],
["rotationX", "rotateX"],
["rotationY", "rotateY"],
["rotationZ", "rotateZ"],
// anime.js v4 uses transformOrigin and perspective unchanged. GSAP's
// transformPerspective alias is folded into CSS perspective and warned on.
["transformPerspective", "perspective"],
]);
export function convertGsapScript(script: string, parsed: ParsedGsap): string {
const registration = parseRegistrationPostamble(parsed.postamble, parsed.timelineVar);
const registrationId = registration?.id ?? "main";
const rawPreamble = convertPreamble(parsed.preamble, parsed.timelineVar);
const bodyIndent = wrapperBodyIndent(rawPreamble);
const preamble = bodyIndent ? indentPreambleBody(rawPreamble, bodyIndent) : rawPreamble;
const registrationCall = `${indentBlock(
renderRegistration(registrationId, parsed.timelineVar, extractGsapLabels(script)),
bodyIndent,
)}${renderTrailingCloser(registration?.trailing ?? "")}`;
const lines = [
preamble,
...parsed.animations.map((animation) =>
indentBlock(convertAnimation(animation, parsed.timelineVar), bodyIndent),
),
registrationCall,
];
return `\n${lines.filter((line) => line.length > 0).join("\n")}\n`;
}
function convertPreamble(preamble: string, timelineVar: string): string {
const withoutRegistry = stripLegacyRegistrySetup(preamble).trim();
const rewrittenUtils = rewriteGsapToArray(withoutRegistry);
const timelineDecl = new RegExp(
`\\b(const|let|var)\\s+${escapeRegExp(timelineVar)}\\s*=\\s*gsap\\.timeline\\s*\\([^;]*\\)\\s*;?`,
);
const converted = rewrittenUtils.replace(
timelineDecl,
`const ${timelineVar} = anime.createTimeline({ autoplay: false });`,
);
return converted
.split("\n")
.map((line) => line.trimStart())
.join("\n");
}
function rewriteGsapToArray(source: string): string {
return source.replace(
/gsap\.utils\.toArray\s*\(\s*(["'][^"']+["'])\s*\)/g,
"[...document.querySelectorAll($1)]",
);
}
function wrapperBodyIndent(preamble: string): string {
const firstLine = preamble.split("\n").find((line) => line.trim().length > 0);
return firstLine?.trimEnd().endsWith("{") ? " " : "";
}
function indentPreambleBody(preamble: string, indent: string): string {
const lines = preamble.split("\n");
return lines
.map((line, index) => (index === 0 || line.trim().length === 0 ? line : `${indent}${line}`))
.join("\n");
}
function indentBlock(block: string, indent: string): string {
if (indent.length === 0) return block;
return block
.split("\n")
.map((line) => (line.trim().length === 0 ? line : `${indent}${line}`))
.join("\n");
}
function convertAnimation(animation: GsapAnimation, timelineVar: string): string {
const target = JSON.stringify(animation.targetSelector);
const properties = buildProperties(animation);
const position = milliseconds(animation.resolvedStart ?? 0);
return renderAddCall(timelineVar, target, properties, position);
}
function buildProperties(animation: GsapAnimation): AnimeProperty[] {
const entries: AnimeProperty[] = [];
for (const [key, value] of Object.entries(animation.properties)) {
pushConvertedProperty(entries, animation, key, value);
}
entries.push({ key: "duration", code: String(durationMs(animation)) });
if (animation.ease)
entries.push({ key: "ease", code: JSON.stringify(resolveEase(animation.ease).animeEase) });
const stagger = renderStagger(animation.extras?.stagger);
if (stagger) entries.push({ key: "delay", code: stagger });
const repeat = renderRepeat(animation.extras?.repeat);
if (repeat) entries.push({ key: "loop", code: repeat });
if (rawBoolean(animation.extras?.yoyo) === true) entries.push({ key: "alternate", code: "true" });
return entries;
}
function pushConvertedProperty(
entries: AnimeProperty[],
animation: GsapAnimation,
key: string,
value: number | string,
): void {
if (key === "attr") {
pushAttrProperties(entries, animation, value);
return;
}
if (key === "autoAlpha") {
pushAutoAlpha(entries, animation, value);
return;
}
const animeKey = PROP_RENAMES.get(key) ?? key;
entries.push({ key: animeKey, code: renderTweenValue(animation, key, value) });
}
function pushAttrProperties(
entries: AnimeProperty[],
animation: GsapAnimation,
value: number | string,
): void {
const attrs = parseAttrWrapper(value);
if (attrs === null) return;
const fromAttrs = parseAttrWrapper(animation.fromProperties?.attr);
for (const attr of attrs) {
entries.push({ key: attr.key, code: renderAttrTweenValue(animation.method, attr, fromAttrs) });
}
}
function renderAttrTweenValue(
method: GsapAnimation["method"],
attr: AttrEntry,
fromAttrs: AttrEntry[] | null,
): string {
const from = fromAttrs?.find((entry) => entry.key === attr.key);
if (method === "fromTo" && from) return serializeArray([from.value, attr.value]);
if (method === "from") return serializeArray([attr.value, identityValue(attr.key)]);
return serializeValue(attr.value);
}
function pushAutoAlpha(
entries: AnimeProperty[],
animation: GsapAnimation,
value: number | string,
): void {
const from = animation.fromProperties?.autoAlpha;
if (typeof from === "number" || typeof from === "string") {
entries.push({ key: "opacity", code: serializeArray([from, value]) });
entries.push({
key: "visibility",
code: serializeArray([visibilityFor(from), visibilityFor(value)]),
});
return;
}
entries.push({ key: "opacity", code: serializeValue(value) });
entries.push({ key: "visibility", code: serializeValue(visibilityFor(value)) });
}
function renderTweenValue(animation: GsapAnimation, key: string, value: number | string): string {
if (animation.method === "fromTo") {
const fromValue = animation.fromProperties?.[key];
return fromValue === undefined ? serializeValue(value) : serializeArray([fromValue, value]);
}
if (animation.method === "from") {
return serializeArray([value, identityValue(key)]);
}
if (animation.method === "to") {
const fromValue = animation.fromProperties?.[key];
return fromValue === undefined ? serializeValue(value) : serializeArray([fromValue, value]);
}
return serializeValue(value);
}
function identityValue(key: string): number | string {
if (key === "opacity" || key === "autoAlpha") return 1;
if (key === "visibility") return "visible";
if (key.startsWith("scale")) return 1;
return 0;
}
function visibilityFor(value: number | string): string {
if (typeof value === "number") return value <= 0 ? "hidden" : "visible";
return value === "0" || value === "hidden" ? "hidden" : "visible";
}
function durationMs(animation: GsapAnimation): number {
if (animation.method === "set") return 0;
return milliseconds(animation.duration ?? 0.5);
}
function milliseconds(seconds: number): number {
return Math.round(seconds * 1000);
}
function renderStagger(value: unknown): string | null {
const raw = rawText(value);
if (!raw) return null;
if (/function|=>/.test(raw)) return null;
const numeric = Number(raw);
if (Number.isFinite(numeric)) return `anime.stagger(${milliseconds(numeric)})`;
if (!raw.trim().startsWith("{")) return null;
return renderObjectStagger(raw);
}
function renderObjectStagger(raw: string): string | null {
const keys = [...raw.matchAll(/([A-Za-z_$][\w$]*)\s*:/g)].map((match) => match[1] ?? "");
if (
keys.some((key) => !["each", "amount", "from", "grid", "axis", "start", "ease"].includes(key))
) {
return null;
}
const seconds = numericField(raw, "each") ?? numericField(raw, "amount");
if (seconds === null) return null;
const options: string[] = [];
const grid = /\bgrid\s*:\s*(\[[^\]]+\])/.exec(raw)?.[1];
const from = /\bfrom\s*:\s*("[^"]+"|'[^']+'|[A-Za-z_$][\w$]*)/.exec(raw)?.[1];
const axis = /\baxis\s*:\s*("[^"]+"|'[^']+'|[A-Za-z_$][\w$]*)/.exec(raw)?.[1];
const start = numericField(raw, "start");
const ease = /\bease\s*:\s*("[^"]+"|'[^']+')/.exec(raw)?.[1];
if (grid) options.push(`grid: ${grid}`);
if (from) options.push(`from: ${normalizeStringLiteral(from)}`);
if (axis) options.push(`axis: ${normalizeStringLiteral(axis)}`);
if (start !== null) options.push(`start: ${milliseconds(start)}`);
if (ease) options.push(`ease: ${normalizeStringLiteral(ease)}`);
return options.length > 0
? `anime.stagger(${milliseconds(seconds)}, { ${options.join(", ")} })`
: `anime.stagger(${milliseconds(seconds)})`;
}
function numericField(raw: string, key: string): number | null {
const match = new RegExp(`\\b${key}\\s*:\\s*(-?\\d+(?:\\.\\d+)?)`).exec(raw);
if (!match?.[1]) return null;
const value = Number(match[1]);
return Number.isFinite(value) ? value : null;
}
function renderRepeat(value: unknown): string | null {
const raw = rawText(value);
if (!raw) return null;
if (raw === "-1" || raw === "Infinity") return "true";
const repeat = Number(raw);
if (!Number.isFinite(repeat)) return null;
return String(Math.max(0, Math.floor(repeat)) + 1);
}
function rawBoolean(value: unknown): boolean | null {
const raw = rawText(value);
if (raw === "true") return true;
if (raw === "false") return false;
return null;
}
function rawText(value: unknown): string | null {
if (typeof value === "string")
return value.startsWith("__raw:") ? value.slice(6).trim() : value.trim();
if (typeof value === "number" || typeof value === "boolean") return String(value);
return null;
}
function normalizeStringLiteral(value: string): string {
if (value.startsWith("'") && value.endsWith("'")) return JSON.stringify(value.slice(1, -1));
return value;
}
function renderRegistration(
id: string,
timelineVar: string,
labels: Array<{ name: string; position: number }>,
): string {
if (labels.length === 0)
return `hyperframesAnime.register(${JSON.stringify(id)}, ${timelineVar});`;
const labelEntries = labels.map(
(label) => `${safeKey(label.name)}: ${formatNumber(label.position)}`,
);
return `hyperframesAnime.register(${JSON.stringify(id)}, ${timelineVar}, { labels: { ${labelEntries.join(", ")} } });`;
}
function renderTrailingCloser(trailing: string): string {
const closer = trailing.trim();
return closer.length === 0 ? "" : `\n${closer}`;
}
function serializeProperties(properties: AnimeProperty[]): string {
return `{ ${properties.map((property) => `${safeKey(property.key)}: ${property.code}`).join(", ")} }`;
}
function renderAddCall(
timelineVar: string,
target: string,
properties: AnimeProperty[],
position: number,
): string {
const props = serializeProperties(properties);
const oneLine = `${timelineVar}.add(${target}, ${props}, ${position});`;
if (properties.some((property) => property.code.startsWith("[")) && oneLine.length > 95) {
return `${timelineVar}.add(\n ${target},\n ${props},\n ${position},\n);`;
}
if (oneLine.length <= 110) return oneLine;
if (properties.some((property) => property.code.includes("anime.stagger"))) {
const propLines = properties.map(
(property) => ` ${safeKey(property.key)}: ${property.code},`,
);
return `${timelineVar}.add(\n ${target},\n {\n${propLines.join("\n")}\n },\n ${position},\n);`;
}
return `${timelineVar}.add(\n ${target},\n ${props},\n ${position},\n);`;
}
function serializeArray(values: Array<number | string>): string {
return `[${values.map(serializeValue).join(", ")}]`;
}
function serializeValue(value: number | string): string {
return typeof value === "number" ? formatNumber(value) : JSON.stringify(value);
}
function safeKey(key: string): string {
return /^[A-Za-z_$][\w$]*$/.test(key) ? key : JSON.stringify(key);
}
function formatNumber(value: number): string {
const rounded = Math.round(value * 100000) / 100000;
return String(rounded);
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
File diff suppressed because it is too large Load Diff
+170
View File
@@ -0,0 +1,170 @@
# GSAP to anime.js dry-run report
## Totals
- converted: 39
- converted-with-warnings: 3
- manual: 105
- total: 147
## Manual reason breakdown
- computed-timeline: 99
- unrecognized-postamble: 40
- non-selector-target: 33
- splitText: 13
- unsupported-extra: 5
- customEase-beyond-shim: 4
- attr-wrapper: 3
- keyframes: 1
- plugin:Flip: 1
## Items
- manual: blocks/app-showcase (computed-timeline)
- manual: blocks/apple-money-count (computed-timeline, non-selector-target)
- manual: blocks/blue-sweater-intro-video (unsupported-extra, computed-timeline, keyframes)
- manual: blocks/chromatic-radial-split (computed-timeline)
- manual: blocks/cinematic-zoom (computed-timeline)
- manual: blocks/code-3d-extrude (non-selector-target, unrecognized-postamble)
- manual: blocks/code-diff (computed-timeline, non-selector-target, unrecognized-postamble)
- manual: blocks/code-highlight (computed-timeline, non-selector-target, unrecognized-postamble)
- manual: blocks/code-morph (computed-timeline, unrecognized-postamble)
- manual: blocks/code-particle-assemble (computed-timeline, non-selector-target, unrecognized-postamble)
- manual: blocks/code-scroll (computed-timeline, non-selector-target, unrecognized-postamble)
- manual: blocks/code-shader-dissolve (computed-timeline, non-selector-target, unrecognized-postamble)
- manual: blocks/code-snippet-apple-terminal-basic (splitText, computed-timeline, unrecognized-postamble)
- manual: blocks/code-snippet-apple-terminal-clear-dark (splitText, computed-timeline, unrecognized-postamble)
- manual: blocks/code-snippet-apple-terminal-clear-light (splitText, computed-timeline, unrecognized-postamble)
- manual: blocks/code-snippet-apple-terminal-grass (splitText, computed-timeline, unrecognized-postamble)
- manual: blocks/code-snippet-apple-terminal-homebrew (splitText, computed-timeline, unrecognized-postamble)
- manual: blocks/code-snippet-apple-terminal-man-page (splitText, computed-timeline, unrecognized-postamble)
- manual: blocks/code-snippet-apple-terminal-novel (splitText, computed-timeline, unrecognized-postamble)
- manual: blocks/code-snippet-apple-terminal-ocean (splitText, computed-timeline, unrecognized-postamble)
- manual: blocks/code-snippet-apple-terminal-pro (splitText, computed-timeline, unrecognized-postamble)
- manual: blocks/code-snippet-apple-terminal-red-sands (splitText, computed-timeline, unrecognized-postamble)
- manual: blocks/code-snippet-apple-terminal-silver-aerogel (splitText, computed-timeline, unrecognized-postamble)
- manual: blocks/code-snippet-apple-terminal-solid-colors (splitText, computed-timeline, unrecognized-postamble)
- manual: blocks/code-snippet-dark-2026 (computed-timeline, unrecognized-postamble)
- manual: blocks/code-snippet-dark-modern (computed-timeline, unrecognized-postamble)
- manual: blocks/code-snippet-dark-plus (computed-timeline, unrecognized-postamble)
- manual: blocks/code-snippet-flight (computed-timeline, non-selector-target, unrecognized-postamble)
- manual: blocks/code-snippet-high-contrast (computed-timeline, unrecognized-postamble)
- manual: blocks/code-snippet-high-contrast-light (computed-timeline, unrecognized-postamble)
- manual: blocks/code-snippet-light-2026 (computed-timeline, unrecognized-postamble)
- manual: blocks/code-snippet-light-modern (computed-timeline, unrecognized-postamble)
- manual: blocks/code-snippet-light-plus (computed-timeline, unrecognized-postamble)
- manual: blocks/code-snippet-monokai (computed-timeline, unrecognized-postamble)
- manual: blocks/code-snippet-solarized-light (computed-timeline, unrecognized-postamble)
- manual: blocks/code-snippet-visual-studio-dark (computed-timeline, unrecognized-postamble)
- manual: blocks/code-snippet-visual-studio-light (computed-timeline, unrecognized-postamble)
- manual: blocks/code-typing (computed-timeline, non-selector-target, unrecognized-postamble)
- manual: blocks/cross-warp-morph (computed-timeline)
- manual: blocks/data-chart (computed-timeline, attr-wrapper, non-selector-target)
- manual: blocks/domain-warp-dissolve (computed-timeline)
- manual: blocks/flash-through-white (computed-timeline)
- manual: blocks/flowchart (computed-timeline, unsupported-extra)
- manual: blocks/flowchart-vertical (computed-timeline, unsupported-extra)
- manual: blocks/glitch (computed-timeline)
- manual: blocks/gravitational-lens (computed-timeline)
- converted: blocks/instagram-follow
- manual: blocks/ios26-liquid-glass (computed-timeline, non-selector-target, customEase-beyond-shim)
- manual: blocks/light-leak (computed-timeline)
- manual: blocks/liquid-glass-context-menu (non-selector-target)
- manual: blocks/liquid-glass-media-controls (computed-timeline, non-selector-target)
- manual: blocks/liquid-glass-notification (computed-timeline, non-selector-target)
- manual: blocks/liquid-glass-widgets (computed-timeline, non-selector-target)
- manual: blocks/logo-outro (computed-timeline)
- converted: blocks/lower-third-bild
- converted: blocks/lt-accent-underline
- converted: blocks/lt-bold-block
- converted: blocks/lt-clean-bar
- converted: blocks/lt-color-block
- converted: blocks/lt-dark-card
- converted: blocks/lt-kicker-name
- converted: blocks/lt-mask-reveal
- converted: blocks/lt-side-rule
- converted: blocks/lt-soft-pill
- converted: blocks/lt-stack-bars
- converted: blocks/macos-notification
- manual: blocks/macos-tahoe-liquid-glass (computed-timeline, non-selector-target, customEase-beyond-shim)
- converted: blocks/news-ticker
- converted-with-warnings: blocks/north-korea-locked-down
- converted-with-warnings: blocks/nyc-paris-flight
- manual: blocks/reddit-post (computed-timeline)
- manual: blocks/ridged-burn (computed-timeline)
- manual: blocks/ripple-waves (computed-timeline)
- manual: blocks/sdf-iris (computed-timeline)
- converted: blocks/spain-map
- converted: blocks/spotify-card
- manual: blocks/swirl-vortex (computed-timeline)
- manual: blocks/thermal-distortion (computed-timeline)
- converted: blocks/tiktok-follow
- manual: blocks/transitions-3d (plugin:Flip)
- converted: blocks/transitions-blur
- manual: blocks/transitions-cover (computed-timeline)
- manual: blocks/transitions-destruction (computed-timeline, non-selector-target)
- converted: blocks/transitions-dissolve
- manual: blocks/transitions-distortion (computed-timeline)
- manual: blocks/transitions-grid (computed-timeline)
- converted: blocks/transitions-light
- converted: blocks/transitions-mechanical
- converted: blocks/transitions-other
- converted: blocks/transitions-push
- converted: blocks/transitions-radial
- converted: blocks/transitions-scale
- manual: blocks/ui-3d-reveal (computed-timeline)
- converted: blocks/us-map
- manual: blocks/us-map-bubble (computed-timeline, attr-wrapper)
- manual: blocks/us-map-flow (computed-timeline, non-selector-target)
- manual: blocks/us-map-hex (computed-timeline)
- manual: blocks/vfx-iphone-device (computed-timeline, non-selector-target, customEase-beyond-shim)
- manual: blocks/vfx-liquid-background (splitText, computed-timeline, non-selector-target, unrecognized-postamble)
- manual: blocks/vfx-liquid-glass (non-selector-target, unrecognized-postamble)
- manual: blocks/vfx-magnetic (computed-timeline, non-selector-target, unrecognized-postamble)
- manual: blocks/vfx-portal (computed-timeline, non-selector-target, unrecognized-postamble)
- manual: blocks/vfx-shatter (computed-timeline, non-selector-target, unrecognized-postamble)
- manual: blocks/vfx-text-cursor (computed-timeline, non-selector-target, customEase-beyond-shim)
- converted: blocks/vpn-youtube-spot
- manual: blocks/whip-pan (computed-timeline)
- manual: blocks/world-map (computed-timeline)
- manual: blocks/x-post (computed-timeline)
- converted: blocks/yt-lower-third
- converted: components/caption-blend-difference
- manual: components/caption-clip-wipe (computed-timeline)
- manual: components/caption-editorial-emphasis (computed-timeline, non-selector-target)
- manual: components/caption-emoji-pop (computed-timeline)
- manual: components/caption-glitch-rgb (computed-timeline)
- manual: components/caption-gradient-fill (computed-timeline)
- manual: components/caption-highlight (computed-timeline)
- manual: components/caption-kinetic-slam (computed-timeline)
- manual: components/caption-matrix-decode (computed-timeline)
- manual: components/caption-neon-accent (computed-timeline)
- manual: components/caption-neon-glow (computed-timeline)
- manual: components/caption-parallax-layers (computed-timeline, non-selector-target, unrecognized-postamble)
- manual: components/caption-particle-burst (computed-timeline)
- converted: components/caption-pill-karaoke
- manual: components/caption-texture (computed-timeline)
- manual: components/caption-weight-shift (computed-timeline)
- converted: components/grain-overlay
- converted: components/grid-pixelate-wipe
- manual: components/morph-text (computed-timeline, non-selector-target)
- converted: components/motion-blur
- converted: components/parallax-unzoom
- converted: components/parallax-zoom
- converted: components/shimmer-sweep
- converted: components/texture-mask-text
- converted: components/vignette
- manual: examples/airbnb-deck (non-selector-target)
- manual: examples/decision-tree (computed-timeline, unsupported-extra)
- converted-with-warnings: examples/kinetic-type
- manual: examples/motion-blur (computed-timeline, non-selector-target, unrecognized-postamble)
- manual: examples/nyt-graph (computed-timeline, attr-wrapper, non-selector-target)
- manual: examples/play-mode (computed-timeline)
- manual: examples/product-promo (computed-timeline, unsupported-extra, non-selector-target)
- converted: examples/slideshow-demo
- manual: examples/startup-pitch (non-selector-target)
- manual: examples/swiss-grid (computed-timeline)
- manual: examples/vignelli (computed-timeline)
- manual: examples/vscode-theme-visualizer (computed-timeline)
- manual: examples/warm-grain (computed-timeline)
+133
View File
@@ -0,0 +1,133 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, it } from "node:test";
import { transformHtml } from "./index.ts";
const FIXTURES = join(import.meta.dirname, "__fixtures__");
function fixture(name: string): string {
return readFileSync(join(FIXTURES, name), "utf-8");
}
function reasonCodes(result: ReturnType<typeof transformHtml>): string[] {
return result.classification.reasons.map((reason) => reason.code);
}
function warningCodes(result: ReturnType<typeof transformHtml>): string[] {
return result.classification.warnings.map((warning) => warning.code);
}
describe("gsap-to-anime transform", () => {
it("converts a simple to chain", () => {
const result = transformHtml(fixture("simple-to.before.html"));
assert.equal(result.classification.status, "converted");
assert.equal(result.changed, true);
assert.equal(result.html, fixture("simple-to.after.html"));
});
it("converts fromTo and set calls", () => {
const result = transformHtml(fixture("fromto-set.before.html"));
assert.equal(result.classification.status, "converted");
assert.equal(result.html, fixture("fromto-set.after.html"));
});
it("uses absolute millisecond positions for labels and relative GSAP positions", () => {
const result = transformHtml(fixture("positions.before.html"));
assert.equal(result.classification.status, "converted");
assert.equal(result.html, fixture("positions.after.html"));
});
it("converts a stagger grid to anime.stagger", () => {
const result = transformHtml(fixture("stagger-grid.before.html"));
assert.equal(result.classification.status, "converted");
assert.equal(result.html, fixture("stagger-grid.after.html"));
});
it("converts supported ease families and warns for expo or steps shim divergence", () => {
const result = transformHtml(fixture("eases.before.html"));
assert.equal(result.classification.status, "converted-with-warnings");
assert.deepEqual(warningCodes(result), ["ease-shim-divergence"]);
assert.equal(result.html, fixture("eases.after.html"));
});
it("converts repeat and yoyo to loop and alternate", () => {
const result = transformHtml(fixture("repeat-yoyo.before.html"));
assert.equal(result.classification.status, "converted");
assert.equal(result.html, fixture("repeat-yoyo.after.html"));
});
it("ignores unrelated .add calls outside the parsed timeline", () => {
const result = transformHtml(fixture("unrelated-add.before.html"));
assert.equal(result.classification.status, "converted");
assert.equal(result.html, fixture("unrelated-add.after.html"));
});
it("passes through arbitrary properties and unwraps GSAP attr wrappers", () => {
const result = transformHtml(fixture("passthrough-properties.before.html"));
assert.equal(result.classification.status, "converted");
assert.equal(result.html, fixture("passthrough-properties.after.html"));
});
it("preserves a trailing IIFE close after timeline registration", () => {
const result = transformHtml(fixture("iife-postamble.before.html"));
assert.equal(result.classification.status, "converted");
assert.equal(result.html, fixture("iife-postamble.after.html"));
});
it("scopes callback and delay scans to GSAP tween arguments", () => {
const result = transformHtml(fixture("unrelated-callback-config.before.html"));
assert.equal(result.classification.status, "converted");
assert.equal(result.html, fixture("unrelated-callback-config.after.html"));
});
it("classifies motionPath as manual and leaves the file unchanged", () => {
const before = fixture("motionpath-manual.before.html");
const result = transformHtml(before);
assert.equal(result.classification.status, "manual");
assert.deepEqual(reasonCodes(result), ["motionPath"]);
assert.equal(result.changed, false);
assert.equal(result.html, before);
});
it("classifies a mixed convertible and SplitText script as manual as a whole", () => {
const before = fixture("splittext-manual.before.html");
const result = transformHtml(before);
assert.equal(result.classification.status, "manual");
assert.deepEqual(reasonCodes(result), ["splitText"]);
assert.equal(result.changed, false);
assert.equal(result.html, before);
});
it("classifies real tween callbacks as manual", () => {
const before = fixture("onupdate-manual.before.html");
const result = transformHtml(before);
assert.equal(result.classification.status, "manual");
assert.equal(result.changed, false);
assert.ok(reasonCodes(result).includes("computed-timeline"));
assert.ok(reasonCodes(result).includes("non-selector-target"));
assert.equal(result.html, before);
});
it("is idempotent on already-converted anime code", () => {
const html = fixture("simple-to.after.html");
const result = transformHtml(html);
assert.equal(result.classification.status, "converted");
assert.equal(result.changed, false);
assert.equal(result.html, html);
});
});
+39
View File
@@ -0,0 +1,39 @@
import type { ScriptBlock } from "./types.ts";
const INLINE_SCRIPT_RE = /<script\b(?![^>]*\bsrc\s*=)[^>]*>([\s\S]*?)<\/script>/gi;
export function findGsapScriptBlock(html: string): ScriptBlock | null {
let best: ScriptBlock | null = null;
let bestScore = -1;
for (const match of html.matchAll(INLINE_SCRIPT_RE)) {
const fullMatch = match[0];
const scriptText = match[1] ?? "";
const matchIndex = match.index;
if (matchIndex === undefined) continue;
if (!scriptText.includes("gsap.timeline")) continue;
const openTagEnd = fullMatch.indexOf(">") + 1;
const block = {
scriptText,
start: matchIndex + openTagEnd,
end: matchIndex + fullMatch.length - "</script>".length,
};
const score = timelineMethodScore(scriptText);
if (score > bestScore) {
best = block;
bestScore = score;
}
}
return best;
}
export function replaceScriptBlock(html: string, block: ScriptBlock, scriptText: string): string {
return `${html.slice(0, block.start)}${scriptText}${html.slice(block.end)}`;
}
function timelineMethodScore(scriptText: string): number {
let score = 0;
for (const match of scriptText.matchAll(/\.(?:set|to|from|fromTo)\s*\(/g)) {
if (match[0]) score += 1;
}
return score;
}
+56
View File
@@ -0,0 +1,56 @@
import { parseGsapScriptAcorn } from "@hyperframes/parsers/gsap-parser-acorn";
import { classifyGsapScript } from "./classifier.ts";
import { convertGsapScript } from "./converter.ts";
import { findGsapScriptBlock, replaceScriptBlock } from "./html.ts";
import type { TransformResult } from "./types.ts";
export type {
ClassificationNote,
CodemodClassification,
CodemodStatus,
RegistryFileReport,
RegistryItemReport,
RegistryReport,
TransformResult,
} from "./types.ts";
export { classifyGsapScript } from "./classifier.ts";
export { scanRegistryTree, renderMarkdownReport, assertTotalsReconcile } from "./report.ts";
export function transformHtml(html: string): TransformResult {
const block = findGsapScriptBlock(html);
if (block === null) {
return {
html,
changed: false,
classification: { status: "converted", reasons: [], warnings: [] },
};
}
const parsed = parseGsapScriptAcorn(block.scriptText);
const classification = classifyGsapScript(block.scriptText, parsed);
if (classification.status === "manual") {
return { html, changed: false, classification };
}
const script = preserveClosingIndent(
convertGsapScript(block.scriptText, parsed),
block.scriptText,
);
const nextHtml = replaceScriptBlock(html, block, script);
return { html: nextHtml, changed: nextHtml !== html, classification };
}
function preserveClosingIndent(script: string, previousScript: string): string {
const codeIndent = /\n([ \t]*)\S/.exec(previousScript)?.[1] ?? "";
const closingIndent = /\n([ \t]*)$/.exec(previousScript)?.[1] ?? "";
const lines = trimCommonIndent(script.trim().split("\n"));
return `\n${lines.map((line) => `${codeIndent}${line}`).join("\n")}\n${closingIndent}`;
}
function trimCommonIndent(lines: string[]): string[] {
const indents = lines
.filter((line) => line.trim().length > 0)
.map((line) => line.match(/^[ \t]*/)?.[0].length ?? 0);
const common = indents.length > 0 ? Math.min(...indents) : 0;
return common > 0 ? lines.map((line) => line.slice(common)) : lines;
}
+27
View File
@@ -0,0 +1,27 @@
import type { ClassificationNote, CodemodStatus } from "./types.ts";
export function note(code: string, message = code): ClassificationNote {
return { code, message };
}
export function addUniqueNote(notes: ClassificationNote[], next: ClassificationNote): void {
if (notes.some((note) => note.code === next.code)) return;
notes.push(next);
}
export function statusFor(
reasons: ClassificationNote[],
warnings: ClassificationNote[],
): CodemodStatus {
if (reasons.length > 0) return "manual";
if (warnings.length > 0) return "converted-with-warnings";
return "converted";
}
export function mergeNotes(groups: ClassificationNote[][]): ClassificationNote[] {
const merged: ClassificationNote[] = [];
for (const group of groups) {
for (const entry of group) addUniqueNote(merged, entry);
}
return merged;
}
+50
View File
@@ -0,0 +1,50 @@
import type { RegistrationInfo } from "./types.ts";
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function stripComments(source: string): string {
return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|\s)\/\/[^\n\r]*/g, "$1");
}
function removeRegistrySetup(source: string): string {
return source
.replace(/window\.__timelines\s*=\s*window\.__timelines\s*\|\|\s*\{\}\s*;?/g, "")
.replace(/window\.__timelines\s*=\s*window\.__timelines\s*\|\|\s*\[\]\s*;?/g, "")
.replace(
/if\s*\(\s*!window\.__timelines\s*\)\s*\{\s*window\.__timelines\s*=\s*\{\}\s*;?\s*\}/g,
"",
);
}
export function parseRegistrationPostamble(
postamble: string,
timelineVar: string,
): RegistrationInfo | null {
const trimmed = stripComments(postamble).trim();
if (trimmed === "") return { id: "main", trailing: "" };
const withoutSetup = removeRegistrySetup(trimmed);
const escapedVar = escapeRegExp(timelineVar);
const assignment = new RegExp(
`window\\.__timelines\\s*\\[\\s*["']([^"']+)["']\\s*\\]\\s*=\\s*${escapedVar}\\s*;?`,
"g",
);
const push = new RegExp(`window\\.__timelines\\.push\\s*\\(\\s*${escapedVar}\\s*\\)\\s*;?`, "g");
let id = "main";
let consumed = withoutSetup.replace(assignment, (_match, key: string) => {
id = key;
return "";
});
consumed = consumed.replace(push, "");
return isClosingTail(consumed) ? { id, trailing: consumed } : null;
}
export function stripLegacyRegistrySetup(preamble: string): string {
return removeRegistrySetup(preamble);
}
function isClosingTail(source: string): boolean {
return /^[\s});(]*$/.test(source);
}
+219
View File
@@ -0,0 +1,219 @@
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { join, relative } from "node:path";
import { transformHtml } from "./index.ts";
import { mergeNotes } from "./notes.ts";
import type {
CodemodStatus,
RegistryFileReport,
RegistryItemReport,
RegistryReport,
} from "./types.ts";
interface RegistryFileEntry {
path: string;
type: string;
}
interface RegistryManifest {
name: string;
type: string;
files: RegistryFileEntry[];
}
const STATUSES: CodemodStatus[] = ["converted", "converted-with-warnings", "manual"];
export function scanRegistryTree(root: string): RegistryReport {
const items = discoverRegistryItems(root).map((item) => scanRegistryItem(root, item));
const totals = createTotals();
for (const item of items) totals[item.status] += 1;
return { root, totals, items };
}
export function renderMarkdownReport(report: RegistryReport): string {
const manualBreakdown = manualReasonCounts(report);
const lines = [
"# GSAP to anime.js dry-run report",
"",
"## Totals",
"",
`- converted: ${report.totals.converted}`,
`- converted-with-warnings: ${report.totals["converted-with-warnings"]}`,
`- manual: ${report.totals.manual}`,
`- total: ${report.items.length}`,
"",
"## Manual reason breakdown",
"",
...renderReasonLines(manualBreakdown),
"",
"## Items",
"",
...report.items.map(renderItemLine),
"",
];
return `${lines.join("\n")}`;
}
function discoverRegistryItems(root: string): RegistryManifest[] {
const manifests: RegistryManifest[] = [];
for (const kind of ["blocks", "components", "examples"]) {
const dir = join(root, kind);
if (!existsSync(dir)) continue;
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const itemDir = join(dir, entry.name);
const manifest = readManifest(join(itemDir, "registry-item.json"));
manifests.push(manifest ?? fallbackManifest(kind, entry.name, itemDir));
}
}
return manifests.sort((a, b) => a.type.localeCompare(b.type) || a.name.localeCompare(b.name));
}
function readManifest(path: string): RegistryManifest | null {
if (!existsSync(path)) return null;
const raw: unknown = JSON.parse(readFileSync(path, "utf-8"));
if (!isRecord(raw)) return null;
const name = typeof raw.name === "string" ? raw.name : null;
const type = typeof raw.type === "string" ? raw.type : null;
const files = Array.isArray(raw.files) ? readFiles(raw.files) : [];
return name && type ? { name, type, files } : null;
}
function fallbackManifest(kind: string, name: string, itemDir: string): RegistryManifest {
return {
name,
type: `hyperframes:${kind.slice(0, -1)}`,
files: discoverHtmlFiles(itemDir, itemDir).map((path) => ({
path,
type: "hyperframes:composition",
})),
};
}
function discoverHtmlFiles(root: string, dir: string): string[] {
const files: string[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const path = join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...discoverHtmlFiles(root, path));
} else if (entry.isFile() && entry.name.endsWith(".html")) {
files.push(relative(root, path));
}
}
return files.sort((a, b) => a.localeCompare(b));
}
function readFiles(files: unknown[]): RegistryFileEntry[] {
const entries: RegistryFileEntry[] = [];
for (const file of files) {
if (!isRecord(file)) continue;
if (typeof file.path !== "string" || typeof file.type !== "string") continue;
entries.push({ path: file.path, type: file.type });
}
return entries;
}
function scanRegistryItem(root: string, manifest: RegistryManifest): RegistryItemReport {
const kindDir = kindDirectory(manifest.type);
const itemDir = join(root, kindDir, manifest.name);
const files = compositionFiles(itemDir, manifest)
.map((file) => scanRegistryFile(root, itemDir, file))
.sort((a, b) => a.path.localeCompare(b.path));
const status = combineStatus(files);
return {
kind: kindDir.slice(0, -1),
name: manifest.name,
path: relative(root, itemDir),
status,
changed: files.some((file) => file.changed),
reasons: mergeNotes(files.map((file) => file.reasons)),
warnings: mergeNotes(files.map((file) => file.warnings)),
files,
};
}
function compositionFiles(itemDir: string, manifest: RegistryManifest): RegistryFileEntry[] {
const htmlFiles = manifest.files.filter(
(file) =>
file.path.endsWith(".html") &&
existsSync(join(itemDir, file.path)) &&
(file.type === "hyperframes:composition" || file.type === "hyperframes:snippet"),
);
if (htmlFiles.length > 0) return htmlFiles;
const present = manifest.files.filter(
(file) => file.path.endsWith(".html") && existsSync(join(itemDir, file.path)),
);
if (present.length > 0) return present;
return readdirSync(itemDir, { withFileTypes: true })
.filter((entry) => entry.isFile() && entry.name.endsWith(".html"))
.map((entry) => ({ path: entry.name, type: "hyperframes:composition" }));
}
function scanRegistryFile(
root: string,
itemDir: string,
file: RegistryFileEntry,
): RegistryFileReport {
const path = join(itemDir, file.path);
const result = transformHtml(readFileSync(path, "utf-8"));
return {
path: relative(root, path),
status: result.classification.status,
changed: result.changed,
reasons: result.classification.reasons,
warnings: result.classification.warnings,
};
}
function combineStatus(files: RegistryFileReport[]): CodemodStatus {
if (files.some((file) => file.status === "manual")) return "manual";
if (files.some((file) => file.status === "converted-with-warnings"))
return "converted-with-warnings";
return "converted";
}
function kindDirectory(type: string): string {
if (type.endsWith(":component")) return "components";
if (type.endsWith(":example")) return "examples";
return "blocks";
}
function createTotals(): Record<CodemodStatus, number> {
return {
converted: 0,
"converted-with-warnings": 0,
manual: 0,
};
}
function manualReasonCounts(report: RegistryReport): Map<string, number> {
const counts = new Map<string, number>();
for (const item of report.items) {
if (item.status !== "manual") continue;
for (const reason of item.reasons) counts.set(reason.code, (counts.get(reason.code) ?? 0) + 1);
}
return new Map([...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])));
}
function renderReasonLines(counts: Map<string, number>): string[] {
if (counts.size === 0) return ["- none"];
return [...counts.entries()].map(([reason, count]) => `- ${reason}: ${count}`);
}
function renderItemLine(item: RegistryItemReport): string {
const notes =
item.reasons.length > 0 ? ` (${item.reasons.map((reason) => reason.code).join(", ")})` : "";
return `- ${item.status}: ${item.path}${notes}`;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
export function assertTotalsReconcile(report: RegistryReport): void {
const total = STATUSES.reduce((sum, status) => sum + report.totals[status], 0);
if (total !== report.items.length) {
throw new Error(
`Dry-run totals do not reconcile: ${total} totals for ${report.items.length} items`,
);
}
}
+57
View File
@@ -0,0 +1,57 @@
import type { ParsedGsap } from "@hyperframes/parsers";
export type CodemodStatus = "converted" | "converted-with-warnings" | "manual";
export interface ClassificationNote {
code: string;
message: string;
}
export interface CodemodClassification {
status: CodemodStatus;
reasons: ClassificationNote[];
warnings: ClassificationNote[];
parsed?: ParsedGsap;
}
export interface TransformResult {
html: string;
changed: boolean;
classification: CodemodClassification;
}
export interface ScriptBlock {
scriptText: string;
start: number;
end: number;
}
export interface RegistrationInfo {
id: string;
trailing: string;
}
export interface RegistryFileReport {
path: string;
status: CodemodStatus;
changed: boolean;
reasons: ClassificationNote[];
warnings: ClassificationNote[];
}
export interface RegistryItemReport {
kind: string;
name: string;
path: string;
status: CodemodStatus;
changed: boolean;
reasons: ClassificationNote[];
warnings: ClassificationNote[];
files: RegistryFileReport[];
}
export interface RegistryReport {
root: string;
totals: Record<CodemodStatus, number>;
items: RegistryItemReport[];
}