Files
hyperframes/packages/parsers/src/gsapParser.test-helpers.ts
T
Miguel Ángel cdf9c817e1 refactor: extract @hyperframes/parsers from core (#1755)
## 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
2026-06-27 00:46:26 -04:00

132 lines
4.1 KiB
TypeScript

// fallow-ignore-file dead-code
import { expect } from "vitest";
import {
parseGsapScript,
serializeGsapAnimations,
convertToKeyframesInScript,
} from "./gsapParser.js";
import type { GsapAnimation, GsapPercentageKeyframe } from "./gsapParser.js";
/**
* Parse a script and serialize the result, returning both the parsed output
* and the serialized string for assertion. Shared across gsapParser.test.ts
* and gsapParser.stress.test.ts.
*/
export function parseAndSerialize(script: string) {
const parsed = parseGsapScript(script);
const serialized = serializeGsapAnimations(parsed.animations, parsed.timelineVar, {
preamble: parsed.preamble,
postamble: parsed.postamble,
});
return { parsed, serialized };
}
/**
* Parse a script expecting exactly one animation, and return it directly.
*/
export function parseSingleAnimation(script: string): GsapAnimation {
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(1);
return result.animations[0]!;
}
/**
* Assert that a parsed animation's stagger extra exists and contains
* the expected substrings (as a __raw: prefixed string).
*/
export function expectStaggerRaw(anim: GsapAnimation, ...expectedSubstrings: string[]): void {
expect(anim.extras).toBeDefined();
expect(anim.extras!.stagger).toBeDefined();
const stagger = String(anim.extras!.stagger);
expect(stagger.startsWith("__raw:")).toBe(true);
for (const sub of expectedSubstrings) {
expect(stagger).toContain(sub);
}
}
/**
* Assert a single keyframe's percentage, properties, and optional ease.
*/
export function expectKeyframe(
kf: GsapPercentageKeyframe,
percentage: number,
properties: Record<string, number | string>,
ease?: string,
): void {
expect(kf.percentage).toBe(percentage);
for (const [key, value] of Object.entries(properties)) {
expect(kf.properties[key]).toBe(value);
}
if (ease !== undefined) {
expect(kf.ease).toBe(ease);
}
}
/**
* Assert that an animation has a defined keyframes block with the expected format
* and count, and return the keyframes array for further assertions.
*/
export function expectKeyframesFormat(
anim: GsapAnimation,
format: string,
count: number,
): GsapPercentageKeyframe[] {
expect(anim.keyframes).toBeDefined();
expect(anim.keyframes!.format).toBe(format);
expect(anim.keyframes!.keyframes).toHaveLength(count);
return anim.keyframes!.keyframes;
}
/**
* Parse a script expecting one animation, assert that `rawProp` is a __raw: string
* and `resolvableProp` has the expected value.
*/
export function expectRawWithResolvable(
script: string,
rawProp: string,
resolvableProp: string,
resolvableValue: number | string,
): void {
const anim = parseSingleAnimation(script);
const val = anim.properties[rawProp];
expect(typeof val === "string" && val.startsWith("__raw:")).toBe(true);
expect(anim.properties[resolvableProp]).toBe(resolvableValue);
}
/**
* Parse a script expecting one animation, assert that `position` matches the expected value.
*/
export function expectSingleAnimPosition(script: string, position: number): void {
const anim = parseSingleAnimation(script);
expect(anim.position).toBe(position);
}
/**
* Parse a script, get the first animation id, run convertToKeyframesInScript,
* reparse, and return the first animation for assertion.
*/
export function convertAndReparse(
script: string,
runtimeValues?: Record<string, number | string>,
): GsapAnimation {
const id = parseSingleAnimation(script).id;
const updated = convertToKeyframesInScript(script, id, runtimeValues);
return parseSingleAnimation(updated);
}
/**
* Parse a script, return the first animation and run a split-related reparse.
* Asserts the reparse result has exactly `expectedCount` animations and returns
* the selector of the first animation.
*/
export function parseSplitAndAssert(
script: string,
splitFn: (s: string) => string,
expectedCount: number,
): string[] {
const result = splitFn(script);
const parsed = parseGsapScript(result);
expect(parsed.animations).toHaveLength(expectedCount);
return parsed.animations.map((a) => a.targetSelector);
}