mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-05 13:47:40 +00:00
Merge branch 'animejs-u9' into worktree-feat-animejs-first-party
This commit is contained in:
+1
-1
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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, "\\$&");
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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
@@ -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)
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
Reference in New Issue
Block a user