mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-12 23:29:50 +00:00
## Summary Extracts the GSAP parser/writer suite, HTML parser, hf-ids, spring-ease, and the shared composition data types out of `@hyperframes/core/src/parsers/` into a new, independently-publishable **`@hyperframes/parsers`** package. This is the foundation of the [#1749](https://github.com/heygen-com/hyperframes/issues/1749) effort: make HyperFrames' parsing/linting/validation usable as plain libraries in a Node app, without shelling out to the CLI. Parsers is the standalone base every other extracted package builds on. **Part 1 of 3** — splits #1754 into independently-reviewable pieces. Parts 2 (lint) and 3 (studio-server) stack on this branch. ## What moves | | | |---|---| | Source moved out of core | **~9,900 LOC** (`src/parsers/` → `packages/parsers/src/`) | | Total lines removed from core (incl. tests + goldens) | ~19,600 | | Files relocated | 39 | | Tests carried over | **660 passing** (5 skipped, 3 todo) | The big movers: `gsapParser` / `gsapParserAcorn` (the recast + acorn dual parsers), `gsapWriterAcorn`, `gsapSerialize`, `gsapUnroll`, `htmlParser`, `hfIds`, `springEase`, `stableIds`, plus the `__goldens__` corpus. ## Bundle footprint of the new package | Artifact | Size | |---|---| | `dist/` (unpacked) | 1.7 MB | | npm tarball (packed) | 409 KB | | `dist/index.js` | 90 KB (**~21 KB gzipped**) | | Heaviest entries | `gsapWriterAcorn.js` 93 KB · `gsapParser.js` 91 KB | Most of the weight is the GSAP AST machinery (recast/babel/acorn). It's tree-shakeable via subpath entries (`@hyperframes/parsers/hf-ids`, `/gsap-constants`, etc.) so a consumer that only needs `hf-ids` (2 KB) doesn't pull the parsers. ## How `@hyperframes/core` changes The interesting part: **core sheds its entire AST toolchain.** | core `dependencies` | before | after | |---|---|---| | count | 9 | 6 | | removed | — | `@babel/parser`, `acorn`, `acorn-walk`, `magic-string`, `recast` | | added | — | `@hyperframes/parsers`, `linkedom` | Before this PR, importing `@hyperframes/core` at all dragged in babel + recast + acorn just to construct types. Now those live behind `@hyperframes/parsers`, and a consumer that only wants core's runtime/compiler types never resolves the parser stack. Core keeps thin `@deprecated` re-export stubs at the old subpaths (`@hyperframes/core/gsap-parser`, `/gsap-constants`, …) so nothing downstream breaks. ## Design notes - **`"bun"` export condition before `"node"`** in every package export. Bun resolves the TypeScript source directly (no pre-built `dist/`), while Node/tsx/Docker contexts fall through to `"node"` → `dist/`. This keeps the dev loop zero-build while published artifacts stay Node-consumable. - `@hyperframes/parsers` is **standalone** — zero `@hyperframes/*` dependencies — so it can be the base of the stack. ## Test plan - [x] `bun run --filter @hyperframes/parsers test` — 660 tests pass - [x] `bun run --filter @hyperframes/sdk test` — 382 tests pass - [x] `bun run build` — full monorepo build succeeds - [x] Fallow audit passes on CI
144 lines
6.3 KiB
TypeScript
144 lines
6.3 KiB
TypeScript
/**
|
|
* Unroll computed GSAP timelines (helpers / bounded loops) into explicit literal
|
|
* tweens — the source-rewrite behind the Studio "Unroll to edit" action.
|
|
*
|
|
* Strategy: the read parser already resolves each computed tween (positions,
|
|
* motionPath arcs, keyframes, provenance). We serialize those resolved
|
|
* animations back to literal `tl.*` statements and surgically replace the
|
|
* top-level helper-call / loop statements that produced them (and drop the now
|
|
* dead helper declarations) via magic-string, leaving the rest of the source —
|
|
* literal tweens, comments, formatting — untouched. The result is a visual
|
|
* no-op: re-parsing it yields the same animations, now all literal.
|
|
*
|
|
* Scope: top-level helper calls and loops (the common authoring shape). Tweens
|
|
* whose origin can't be mapped to a top-level statement (e.g. helpers nested
|
|
* inside other helpers) are left as-is rather than guessed at.
|
|
*/
|
|
import * as acorn from "acorn";
|
|
import MagicString from "magic-string";
|
|
import type { GsapAnimation } from "./gsapSerialize.js";
|
|
import { serializeValue as valueToCode, safeJsKey as safeKey } from "./gsapSerialize.js";
|
|
import { parseGsapScriptAcorn } from "./gsapParserAcorn.js";
|
|
|
|
// acorn nodes are structurally untyped here.
|
|
type Node = any;
|
|
|
|
function propEntries(props: Record<string, number | string>): string[] {
|
|
return Object.entries(props).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`);
|
|
}
|
|
|
|
function motionPathEntry(anim: GsapAnimation): string {
|
|
const waypoints = (anim.keyframes?.keyframes ?? [])
|
|
.filter((k) => typeof k.properties.x === "number" && typeof k.properties.y === "number")
|
|
.map((k) => `{ x: ${valueToCode(k.properties.x!)}, y: ${valueToCode(k.properties.y!)} }`);
|
|
const curviness = anim.arcPath?.segments[0]?.curviness ?? 1;
|
|
const autoRotate = anim.arcPath?.autoRotate;
|
|
const extra = autoRotate ? `, autoRotate: ${valueToCode(autoRotate as number | string)}` : "";
|
|
return `motionPath: { path: [${waypoints.join(", ")}], curviness: ${curviness}${extra} }`;
|
|
}
|
|
|
|
function keyframesEntry(anim: GsapAnimation): string {
|
|
const kfs = (anim.keyframes?.keyframes ?? []).map((k) => {
|
|
const body = propEntries(k.properties);
|
|
if (k.ease) body.push(`ease: ${valueToCode(k.ease)}`);
|
|
return `"${k.percentage}%": { ${body.join(", ")} }`;
|
|
});
|
|
if (anim.keyframes?.easeEach) kfs.push(`easeEach: ${valueToCode(anim.keyframes.easeEach)}`);
|
|
return `keyframes: { ${kfs.join(", ")} }`;
|
|
}
|
|
|
|
/** The vars-object entries for a tween: motionPath/keyframes block, props, duration, ease, extras. */
|
|
function buildVarsParts(anim: GsapAnimation): string[] {
|
|
const parts: string[] = [];
|
|
if (anim.arcPath?.enabled) parts.push(motionPathEntry(anim));
|
|
else if (anim.keyframes) parts.push(keyframesEntry(anim));
|
|
parts.push(...propEntries(anim.properties));
|
|
if (anim.method !== "set" && anim.duration !== undefined) {
|
|
parts.push(`duration: ${valueToCode(anim.duration)}`);
|
|
}
|
|
if (anim.ease) parts.push(`ease: ${valueToCode(anim.ease)}`);
|
|
for (const [k, v] of Object.entries(anim.extras ?? {})) {
|
|
parts.push(`${safeKey(k)}: ${valueToCode(v as number | string)}`);
|
|
}
|
|
return parts;
|
|
}
|
|
|
|
/** Serialize one resolved animation to a literal `tl.*` statement (arc/keyframe-aware). */
|
|
function serializeTweenStatement(timelineVar: string, anim: GsapAnimation): string {
|
|
const obj = `{ ${buildVarsParts(anim).join(", ")} }`;
|
|
const pos = valueToCode(
|
|
anim.resolvedStart ?? (typeof anim.position === "number" ? anim.position : 0),
|
|
);
|
|
const sel = valueToCode(anim.targetSelector);
|
|
if (anim.method === "fromTo") {
|
|
const from = `{ ${propEntries(anim.fromProperties ?? {}).join(", ")} }`;
|
|
return `${timelineVar}.fromTo(${sel}, ${from}, ${obj}, ${pos});`;
|
|
}
|
|
return `${timelineVar}.${anim.method}(${sel}, ${obj}, ${pos});`;
|
|
}
|
|
|
|
/** A computed animation is one expanded from a helper or loop (not literal/dynamic). */
|
|
function isComputed(anim: GsapAnimation): boolean {
|
|
return anim.provenance?.kind === "helper" || anim.provenance?.kind === "loop";
|
|
}
|
|
|
|
/** Top-level statements of the parsed program. */
|
|
function topLevelStatements(script: string): Node[] {
|
|
return acorn.parse(script, { ecmaVersion: "latest", sourceType: "script" }).body ?? [];
|
|
}
|
|
|
|
/** The top-level statement whose source span contains [start, end], or null. */
|
|
function enclosingTopLevel(statements: Node[], start: number, end: number): Node | null {
|
|
for (const stmt of statements) {
|
|
if (stmt.start <= start && stmt.end >= end) return stmt;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function isHelperDeclNamed(stmt: Node, names: Set<string>): boolean {
|
|
if (stmt.type === "FunctionDeclaration") return names.has(stmt.id?.name);
|
|
if (stmt.type === "VariableDeclaration") {
|
|
return (stmt.declarations ?? []).some((d: Node) => names.has(d.id?.name));
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Rewrite `script` so top-level helper calls / loops that build the timeline
|
|
* become explicit literal tweens. Returns the original script unchanged when
|
|
* there is nothing statically-resolvable to unroll.
|
|
*/
|
|
export function unrollComputedTimeline(script: string): string {
|
|
const parsed = parseGsapScriptAcorn(script);
|
|
const computed = parsed.animations.filter((a) => isComputed(a) && a.provenance?.sourceRange);
|
|
if (computed.length === 0) return script;
|
|
|
|
const statements = topLevelStatements(script);
|
|
|
|
// Group computed animations by the top-level statement that produced them,
|
|
// preserving source order within each group.
|
|
const byStatement = new Map<Node, GsapAnimation[]>();
|
|
const helperNames = new Set<string>();
|
|
for (const anim of computed) {
|
|
if (anim.provenance?.fn) helperNames.add(anim.provenance.fn);
|
|
const [s, e] = anim.provenance!.sourceRange!;
|
|
const stmt = enclosingTopLevel(statements, s, e);
|
|
if (!stmt) continue; // nested origin — leave it; can't map to a top-level edit
|
|
const list = byStatement.get(stmt) ?? [];
|
|
list.push(anim);
|
|
byStatement.set(stmt, list);
|
|
}
|
|
if (byStatement.size === 0) return script;
|
|
|
|
const ms = new MagicString(script);
|
|
for (const [stmt, anims] of byStatement) {
|
|
const literals = anims.map((a) => serializeTweenStatement(parsed.timelineVar, a)).join("\n");
|
|
ms.overwrite(stmt.start, stmt.end, literals);
|
|
}
|
|
// Drop the now-dead helper declarations.
|
|
for (const stmt of statements) {
|
|
if (isHelperDeclNamed(stmt, helperNames)) ms.remove(stmt.start, stmt.end);
|
|
}
|
|
return ms.toString();
|
|
}
|