mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-15 13:58:20 +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
252 lines
9.1 KiB
TypeScript
252 lines
9.1 KiB
TypeScript
/**
|
|
* SDK document model — adaptation layer on top of @hyperframes/core.
|
|
*
|
|
* F6 decision: SDK builds ON core, no parser duplication.
|
|
* - ensureHfIds (from core) is the parse entry point: all construction starts here.
|
|
* - DOMParser is NOT used (browser-only). linkedom is the node-safe primitive.
|
|
* - ParsedHtml (core) is the Studio timeline view (timed elements only).
|
|
* HyperFramesElement is the editing view (ALL editable elements, with raw attrs).
|
|
*/
|
|
|
|
import { parseHTML } from "linkedom";
|
|
import { ensureHfIds } from "@hyperframes/parsers/hf-ids";
|
|
import { parseGsapScriptAcornForWrite } from "@hyperframes/core/gsap-parser-acorn";
|
|
import { findRoot, getElementStyles, getOwnText, isNewHostBoundary } from "./engine/model.js";
|
|
import type { HyperFramesElement, SdkDocument } from "./types.js";
|
|
|
|
// Tags that carry no editable content and must not enter the element tree.
|
|
const EXCLUDED_TAGS = new Set([
|
|
"script",
|
|
"style",
|
|
"template",
|
|
"meta",
|
|
"link",
|
|
"noscript",
|
|
"base",
|
|
"head",
|
|
]);
|
|
|
|
// Snapshot text is TRIMMED for display (markup indentation produces noisy
|
|
// whitespace text nodes). The raw text target is shared with setText so shadow
|
|
// value checks and dispatch serialization use the same DOM target.
|
|
function snapshotText(el: Element): string | null {
|
|
const trimmed = getOwnText(el).trim();
|
|
return trimmed.length > 0 ? trimmed : null;
|
|
}
|
|
|
|
// Parsing the GSAP script (acorn AST walk) is the expensive part and depends
|
|
// only on the script text, so memoize the {tween id, selector} pairs by script.
|
|
// Selector→hf-id resolution still runs each call — it depends on the live DOM,
|
|
// which changes on dispatch. Single-entry cache covers the hot path (same comp,
|
|
// repeated getElements() rebuilds) and stays bounded.
|
|
let gsapLocatedCacheKey: string | null = null;
|
|
let gsapLocatedCacheVal: Array<{ id: string; selector: string }> = [];
|
|
|
|
function parseLocatedCached(script: string): Array<{ id: string; selector: string }> {
|
|
if (gsapLocatedCacheKey === script) return gsapLocatedCacheVal;
|
|
const parsed = parseGsapScriptAcornForWrite(script);
|
|
gsapLocatedCacheVal = parsed
|
|
? parsed.located.map(({ id, animation }) => ({ id, selector: animation.targetSelector }))
|
|
: [];
|
|
gsapLocatedCacheKey = script;
|
|
return gsapLocatedCacheVal;
|
|
}
|
|
|
|
/**
|
|
* Map each element's data-hf-id → the GSAP tween ids targeting it. Tween ids
|
|
* come from the acorn parser's stable `targetSelector-method-position` scheme —
|
|
* the SAME id-space the studio-api read path and the SDK GSAP ops use, so these
|
|
* ids are dispatchable as-is via setGsapTween/removeGsapTween. Best-effort: a
|
|
* malformed selector or unparseable script yields no entries (animationIds: []).
|
|
*/
|
|
function buildAnimationIdMap(document: Document): Map<string, string[]> {
|
|
const map = new Map<string, string[]>();
|
|
const script = extractGsapScript(document);
|
|
if (!script) return map;
|
|
for (const { id, selector } of parseLocatedCached(script)) {
|
|
if (!selector) continue;
|
|
let matches: Element[] = [];
|
|
try {
|
|
matches = Array.from(document.querySelectorAll(selector));
|
|
} catch {
|
|
continue; // selector not valid for querySelectorAll — skip
|
|
}
|
|
for (const el of matches) {
|
|
const hfId = el.getAttribute("data-hf-id");
|
|
if (!hfId) continue;
|
|
const list = map.get(hfId);
|
|
if (list) list.push(id);
|
|
else map.set(hfId, [id]);
|
|
}
|
|
}
|
|
return map;
|
|
}
|
|
|
|
// fallow-ignore-next-line complexity
|
|
function buildElement(
|
|
el: Element,
|
|
scopePrefix: string,
|
|
animationIdsByHfId: Map<string, string[]>,
|
|
): HyperFramesElement | null {
|
|
const tag = el.tagName.toLowerCase();
|
|
if (EXCLUDED_TAGS.has(tag)) return null;
|
|
|
|
const id = el.getAttribute("data-hf-id") ?? "";
|
|
if (!id) return null; // should never happen after ensureHfIds, but guard defensively
|
|
|
|
// scopedId: if we're inside a sub-comp scope, prefix with "scopePrefix/".
|
|
// The host element itself is in the PARENT scope (no prefix change for its own id).
|
|
const scopedId = scopePrefix ? `${scopePrefix}/${id}` : id;
|
|
|
|
// Children inherit the scope prefix from their parent.
|
|
// If this element is a new host boundary (starts a new sub-comp scope), its
|
|
// children use THIS element's scopedId as their prefix.
|
|
// Otherwise, children inherit the same prefix that this element used.
|
|
const childPrefix = isNewHostBoundary(el) ? scopedId : scopePrefix;
|
|
|
|
const inlineStyles = getElementStyles(el);
|
|
|
|
const classAttr = el.getAttribute("class") ?? "";
|
|
const classNames = classAttr
|
|
.split(/\s+/)
|
|
.map((c) => c.trim())
|
|
.filter(Boolean);
|
|
|
|
const attributes: Record<string, string> = {};
|
|
for (const attr of Array.from(el.attributes)) {
|
|
if (attr.name === "style" || attr.name === "class" || attr.name.startsWith("data-hf-")) {
|
|
continue;
|
|
}
|
|
attributes[attr.name] = attr.value;
|
|
}
|
|
|
|
const startAttr = el.getAttribute("data-start");
|
|
const endAttr = el.getAttribute("data-end");
|
|
const trackAttr = el.getAttribute("data-track-index");
|
|
|
|
const start = startAttr !== null ? parseFloat(startAttr) : null;
|
|
const duration =
|
|
start !== null && endAttr !== null ? Math.max(0, parseFloat(endAttr) - start) : null;
|
|
const trackIndex = trackAttr !== null ? parseInt(trackAttr, 10) : null;
|
|
|
|
const children: HyperFramesElement[] = [];
|
|
for (const child of Array.from(el.children)) {
|
|
const built = buildElement(child, childPrefix, animationIdsByHfId);
|
|
if (built) children.push(built);
|
|
}
|
|
|
|
return {
|
|
id,
|
|
scopedId,
|
|
tag,
|
|
children,
|
|
inlineStyles,
|
|
classNames,
|
|
attributes,
|
|
text: snapshotText(el),
|
|
start,
|
|
duration,
|
|
trackIndex,
|
|
animationIds: animationIdsByHfId.get(id) ?? [],
|
|
};
|
|
}
|
|
|
|
// fallow-ignore-next-line complexity
|
|
function extractGsapScript(doc: Document): string | null {
|
|
// GSAP script is the first <script> tag whose text references gsap. Marker
|
|
// set must match studio sdkShadow.ts isGsapScriptBody so both pick the same
|
|
// script from a given composition.
|
|
for (const script of Array.from(doc.querySelectorAll("script"))) {
|
|
const text = script.textContent ?? "";
|
|
if (text.includes("gsap") || text.includes("__timelines") || text.includes("ScrollTrigger")) {
|
|
return text;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function extractStyles(doc: Document): string | null {
|
|
const styleEl = doc.querySelector("style");
|
|
return styleEl ? styleEl.textContent : null;
|
|
}
|
|
|
|
// Root resolution delegates to the engine's findRoot so dimension extraction
|
|
// and mutations agree on which element is the composition root.
|
|
// fallow-ignore-next-line complexity
|
|
function extractDimensions(doc: Document): { width: number | null; height: number | null } {
|
|
const stage = findRoot(doc);
|
|
if (!stage) return { width: null, height: null };
|
|
// data-width/data-height are the runtime's forced override — prefer them.
|
|
const wAttr = stage.getAttribute("data-width");
|
|
const hAttr = stage.getAttribute("data-height");
|
|
const style = (stage as HTMLElement).getAttribute?.("style") ?? "";
|
|
const wm = /width:\s*(\d+)px/.exec(style);
|
|
const hm = /height:\s*(\d+)px/.exec(style);
|
|
return {
|
|
width: wAttr !== null ? parseInt(wAttr, 10) : wm ? parseInt(wm[1] ?? "", 10) : null,
|
|
height: hAttr !== null ? parseInt(hAttr, 10) : hm ? parseInt(hm[1] ?? "", 10) : null,
|
|
};
|
|
}
|
|
|
|
function extractDuration(doc: Document): number | null {
|
|
const root = findRoot(doc) ?? doc.body;
|
|
const dur = root?.getAttribute("data-duration");
|
|
return dur ? parseFloat(dur) : null;
|
|
}
|
|
|
|
/**
|
|
* Build the element tree from an already-parsed (hf-id-stamped) linkedom Document.
|
|
* Walks the live DOM directly — no serialize/re-parse round trip. This is what
|
|
* the session's query API uses against its mutable document.
|
|
*/
|
|
export function buildRoots(document: Document): HyperFramesElement[] {
|
|
const body = document.body;
|
|
const roots: HyperFramesElement[] = [];
|
|
const animationIdsByHfId = buildAnimationIdMap(document);
|
|
if (body) {
|
|
for (const child of Array.from(body.children)) {
|
|
const built = buildElement(child, "", animationIdsByHfId);
|
|
if (built) roots.push(built);
|
|
}
|
|
}
|
|
return roots;
|
|
}
|
|
|
|
/**
|
|
* Parse an HTML string into the SDK document model.
|
|
* Calls ensureHfIds first so every element has a stable data-hf-id.
|
|
* Uses linkedom — node-safe (works in agents, CI, server-side).
|
|
*/
|
|
export function buildDocument(html: string): SdkDocument {
|
|
const stamped = ensureHfIds(html);
|
|
|
|
const hasShell = /<!doctype|<html[\s>]/i.test(stamped);
|
|
const wrapped = !hasShell;
|
|
const { document } = wrapped
|
|
? parseHTML(`<!DOCTYPE html><html><head></head><body>${stamped}</body></html>`)
|
|
: parseHTML(stamped);
|
|
|
|
const dims = extractDimensions(document);
|
|
|
|
return {
|
|
roots: buildRoots(document),
|
|
gsapScript: extractGsapScript(document),
|
|
styles: extractStyles(document),
|
|
width: dims.width,
|
|
height: dims.height,
|
|
compositionDuration: extractDuration(document),
|
|
html: stamped,
|
|
};
|
|
}
|
|
|
|
/** Flat walk of the element tree — returns every element in document order */
|
|
export function flatElements(roots: readonly HyperFramesElement[]): HyperFramesElement[] {
|
|
const result: HyperFramesElement[] = [];
|
|
function walk(el: HyperFramesElement) {
|
|
result.push(el);
|
|
for (const child of el.children) walk(child);
|
|
}
|
|
for (const root of roots) walk(root);
|
|
return result;
|
|
}
|