mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-12 15:20:13 +00:00
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
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Stable hf- element id minting (R1). Node-safe (linkedom only, not browser DOM).
|
||||
*
|
||||
* Two surfaces share these helpers:
|
||||
* - ensureHfIds(html): node-id surface — mints data-hf-id on every element.
|
||||
* - mintHfId(el, assigned): shared by htmlParser for clip ids.
|
||||
*
|
||||
* Hash is CONTENT ONLY (tag + sorted attrs + own text) — no sibling position,
|
||||
* so inserting a non-identical sibling never shifts another element's id.
|
||||
*/
|
||||
import { parseHTML } from "linkedom";
|
||||
|
||||
// Non-editable / non-visual elements that should never receive a stable id.
|
||||
export const EXCLUDED_TAGS = new Set([
|
||||
"script",
|
||||
"style",
|
||||
"template",
|
||||
"meta",
|
||||
"link",
|
||||
"noscript",
|
||||
"base",
|
||||
]);
|
||||
|
||||
// 32-bit FNV-1a. Pure, deterministic, no crypto, no Math.random.
|
||||
function fnv1a(str: string): number {
|
||||
let h = 0x811c9dc5;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
h ^= str.charCodeAt(i);
|
||||
h = Math.imul(h, 0x01000193);
|
||||
}
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
// 4 base-36 chars · 36^4 ≈ 1.68M ids per document. Birthday-paradox collision
|
||||
// ≈ N²/(2·36^4): well under 1% per document after dup rehash at realistic
|
||||
// clip-model sizes (≤ a few hundred elements). The dup-rehash in mintHfId
|
||||
// resolves the rare collision; width is deliberately small for readable ids.
|
||||
function toHfId(hash: number): string {
|
||||
const s = (hash >>> 0).toString(36);
|
||||
// Use suffix (most-avalanched bits) for better distribution within the 4-char window.
|
||||
const four = s.length >= 4 ? s.slice(-4) : s.padStart(4, "0");
|
||||
return `hf-${four}`;
|
||||
}
|
||||
|
||||
// Element's own direct text (TEXT_NODE children), not descendants'.
|
||||
function ownText(el: Element): string {
|
||||
let text = "";
|
||||
el.childNodes.forEach((n) => {
|
||||
if (n.nodeType === 3) text += (n as Text).nodeValue ?? "";
|
||||
});
|
||||
return text.trim();
|
||||
}
|
||||
|
||||
function contentKey(el: Element): string {
|
||||
// Exclude all data-hf-* attrs (ids, studio state) — they must not influence the hash.
|
||||
// Use \x00 / \x01 separators (invalid in HTML attrs) to prevent ambiguous serialization.
|
||||
const attrs = Array.from(el.attributes)
|
||||
.filter((a) => !a.name.startsWith("data-hf-"))
|
||||
.map((a) => `${a.name}\x00${a.value}`)
|
||||
.sort()
|
||||
.join("\x01");
|
||||
return `${el.tagName.toLowerCase()}|${attrs}|${ownText(el)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collision tiebreak for byte-identical siblings: document-order dup counter
|
||||
* (`hash(key#N)`). This IS order-dependent — two identical `<span></span>`
|
||||
* get different ids based on which comes first in the DOM. This is unavoidable:
|
||||
* unique ids for byte-identical elements require a positional signal.
|
||||
*
|
||||
* Why this is safe in practice: once `ensureHfIds` write-back persists
|
||||
* `data-hf-id` to source the attribute is physically bound to its element.
|
||||
* Reordering identical siblings carries the attribute along → zero
|
||||
* order-dependence post-persist. `ensureHfIds` skips pinned elements
|
||||
* (`if (el.getAttribute("data-hf-id")) continue`), so normal operation
|
||||
* never re-exposes the ordering after first persist.
|
||||
*/
|
||||
// WIRE CONTRACT: id minting is content-keyed (FNV1a of innerHTML + tag). R7's
|
||||
// preview route relies on mintHfId producing identical ids across mint contexts
|
||||
// (disk-persist pass vs. in-memory bundle pass) — see preview.test.ts
|
||||
// "bundle returning untagged HTML gets same ids as disk". Any change that adds
|
||||
// positional, session, or random input to the hash breaks that invariant and
|
||||
// makes hf- ids diverge between disk and served HTML, silently corrupting
|
||||
// drag-to-edit targeting.
|
||||
export function mintHfId(el: Element, assigned: Set<string>): string {
|
||||
const key = contentKey(el);
|
||||
let id = toHfId(fnv1a(key));
|
||||
let dup = 0;
|
||||
while (assigned.has(id)) {
|
||||
dup += 1;
|
||||
// Graceful fallback instead of a hard throw: rehashing only fails to find a
|
||||
// free 4-char slot in a pathological document (~1.6M identical elements).
|
||||
// Rather than crash the whole parse, widen the id with the dup counter —
|
||||
// still deterministic and unique, just longer than the 4-char norm.
|
||||
if (dup > 10000) {
|
||||
id = `hf-${(fnv1a(key) >>> 0).toString(36)}-${dup}`;
|
||||
break;
|
||||
}
|
||||
id = toHfId(fnv1a(`${key}#${dup}`));
|
||||
}
|
||||
assigned.add(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
export function ensureHfIds(html: string): string {
|
||||
// Mirror parseSourceDocument's fragment-wrapping so bare fragments don't land
|
||||
// outside <body> in linkedom, which would cause body.querySelectorAll to return [].
|
||||
const hasDocumentShell = /<!doctype|<html[\s>]/i.test(html);
|
||||
const wrapped = !hasDocumentShell;
|
||||
const { document } = wrapped
|
||||
? parseHTML(`<!DOCTYPE html><html><head></head><body>${html}</body></html>`)
|
||||
: parseHTML(html);
|
||||
const body = document.body;
|
||||
if (!body) return html;
|
||||
|
||||
const assigned = new Set<string>();
|
||||
// Seed with already-present ids (pin) so fresh mints never collide with them.
|
||||
// Scope to <body> to match the mint walk below — a stray data-hf-id in <head>
|
||||
// must not pin an id into the set that a body element would then be bumped off.
|
||||
for (const el of Array.from(body.querySelectorAll("[data-hf-id]"))) {
|
||||
const existing = el.getAttribute("data-hf-id");
|
||||
if (existing) assigned.add(existing);
|
||||
}
|
||||
|
||||
for (const el of Array.from(body.querySelectorAll("*"))) {
|
||||
if (EXCLUDED_TAGS.has(el.tagName.toLowerCase())) continue;
|
||||
if (el.getAttribute("data-hf-id")) continue; // pinned
|
||||
el.setAttribute("data-hf-id", mintHfId(el, assigned));
|
||||
}
|
||||
|
||||
return wrapped ? document.body.innerHTML || "" : document.toString();
|
||||
}
|
||||
Reference in New Issue
Block a user