mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 23:00:03 +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
119 lines
5.3 KiB
TypeScript
119 lines
5.3 KiB
TypeScript
/**
|
|
* @vitest-environment jsdom
|
|
*
|
|
* T2 — Stable id spec (spec for R1).
|
|
*
|
|
* These tests define what "stable hf- id" means BEFORE R1 implements it.
|
|
* They are intentionally red until R1 lands.
|
|
*
|
|
* Currently failing (spec): tests 1, 2, 3 — parser assigns `element-N` not `hf-xxxx`.
|
|
* Currently passing (baseline): tests 4, 5, 6, 7 — these already hold and must not regress.
|
|
*
|
|
* Scope: id assignment and stability only. Round-trip fidelity is T1 territory.
|
|
*/
|
|
import { describe, expect, it } from "vitest";
|
|
import { parseHtml } from "./htmlParser.js";
|
|
import { serialize } from "./test-utils.js";
|
|
|
|
describe("T2 — stable element ids (spec for R1)", () => {
|
|
// --- Spec (red until R1) ---
|
|
|
|
it("[spec] elements without an id get a hf- prefixed id at parse", () => {
|
|
const html = `<html><body><div id="stage">
|
|
<img src="logo.svg" data-start="0" data-end="5" data-name="Logo" />
|
|
<div data-start="0" data-end="5" data-name="Card"><div>Text</div></div>
|
|
</div></body></html>`;
|
|
const { elements } = parseHtml(html);
|
|
for (const el of elements) {
|
|
expect(el.id).toMatch(/^hf-/);
|
|
}
|
|
});
|
|
|
|
it("[spec] generated hf- ids match /^hf-[a-z0-9]{4}$/", () => {
|
|
const html = `<html><body><div id="stage">
|
|
<div data-start="0" data-end="5" data-name="Unnamed"><div>X</div></div>
|
|
<video data-start="1" data-end="6" src="v.mp4" data-name="Clip"></video>
|
|
</div></body></html>`;
|
|
const { elements } = parseHtml(html);
|
|
const noPreExistingId = elements.filter((e) => e.id !== "stage");
|
|
for (const el of noPreExistingId) {
|
|
expect(el.id).toMatch(/^hf-[a-z0-9]{4}$/);
|
|
}
|
|
});
|
|
|
|
it("[spec] adding an element before existing ones does not change existing ids", () => {
|
|
const base = `<html><body><div id="stage">
|
|
<div data-start="0" data-end="5" data-name="AlphaEl"><div>A</div></div>
|
|
<div data-start="1" data-end="6" data-name="BetaEl"><div>B</div></div>
|
|
</div></body></html>`;
|
|
const withPrepend = `<html><body><div id="stage">
|
|
<div data-start="0" data-end="4" data-name="NewEl"><div>New</div></div>
|
|
<div data-start="0" data-end="5" data-name="AlphaEl"><div>A</div></div>
|
|
<div data-start="1" data-end="6" data-name="BetaEl"><div>B</div></div>
|
|
</div></body></html>`;
|
|
const baseAlpha = parseHtml(base).elements.find((e) => e.name === "AlphaEl");
|
|
const extendedAlpha = parseHtml(withPrepend).elements.find((e) => e.name === "AlphaEl");
|
|
expect(baseAlpha).toBeDefined();
|
|
expect(extendedAlpha).toBeDefined();
|
|
// With counter-based ids: base AlphaEl = element-1, extended AlphaEl = element-2 — FAILS.
|
|
// With hf- stable ids: both = same hf-xxxx — PASSES (R1 target).
|
|
expect(extendedAlpha?.id).toBe(baseAlpha?.id);
|
|
});
|
|
|
|
// --- Baseline (already pass, must not regress) ---
|
|
|
|
it("existing data-hf-id is pinned and becomes the clip id (never re-minted)", () => {
|
|
const html = `<html><body><div id="stage">
|
|
<div data-hf-id="hf-anch" data-start="0" data-end="5" data-name="Title"><div>Hi</div></div>
|
|
</div></body></html>`;
|
|
const { elements } = parseHtml(html);
|
|
expect(elements.some((e) => e.id === "hf-anch")).toBe(true);
|
|
});
|
|
|
|
it("ids are deterministic: same input produces same ids on re-parse", () => {
|
|
const html = `<html><body><div id="stage">
|
|
<div data-start="0" data-end="5" data-name="A"><div>A</div></div>
|
|
<div data-start="0" data-end="5" data-name="B"><div>B</div></div>
|
|
</div></body></html>`;
|
|
const first = parseHtml(html).elements.map((e) => e.id);
|
|
const second = parseHtml(html).elements.map((e) => e.id);
|
|
expect(first).toEqual(second);
|
|
});
|
|
|
|
it("ids are unique within a document", () => {
|
|
const html = `<html><body><div id="stage">
|
|
<div data-start="0" data-end="3" data-name="A"><div>A</div></div>
|
|
<div data-start="1" data-end="4" data-name="B"><div>B</div></div>
|
|
<div data-start="2" data-end="5" data-name="C"><div>C</div></div>
|
|
</div></body></html>`;
|
|
const ids = parseHtml(html).elements.map((e) => e.id);
|
|
expect(new Set(ids).size).toBe(ids.length);
|
|
});
|
|
|
|
it("two elements with identical markup get distinct ids (no content-hash collision)", () => {
|
|
// Ensures R1's id derivation includes position or a sibling counter,
|
|
// not just content — two structurally identical elements must not collide.
|
|
const html = `<html><body><div id="stage">
|
|
<div data-start="0" data-end="5" data-name="X"><div>Same</div></div>
|
|
<div data-start="0" data-end="5" data-name="X"><div>Same</div></div>
|
|
</div></body></html>`;
|
|
const { elements } = parseHtml(html);
|
|
const ids = elements.map((e) => e.id);
|
|
expect(new Set(ids).size).toBe(ids.length);
|
|
});
|
|
|
|
it("ids survive a serialize → re-parse round-trip", () => {
|
|
const html = `<html><body><div id="stage">
|
|
<div id="my-anchor" data-start="0" data-end="5" data-name="Anchor"><div>Content</div></div>
|
|
<img src="photo.jpg" data-start="1" data-end="8" data-name="Photo" />
|
|
</div></body></html>`;
|
|
const original = parseHtml(html);
|
|
const reparsed = parseHtml(serialize(original));
|
|
const origIds = original.elements.map((e) => e.id).sort();
|
|
const roundIds = reparsed.elements.map((e) => e.id).sort();
|
|
expect(roundIds).toEqual(origIds);
|
|
});
|
|
|
|
it.todo("sub-composition instances get scoped ids (compositionId/hf-x) — requires SDK session");
|
|
});
|