diff --git a/packages/core/package.json b/packages/core/package.json index c209f838a..42610260b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -95,6 +95,18 @@ "import": "./src/runtime/clipTree.ts", "types": "./src/runtime/clipTree.ts" }, + "./runtime/start-expression": { + "bun": "./src/runtime/startExpression.ts", + "node": "./dist/runtime/startExpression.js", + "import": "./src/runtime/startExpression.ts", + "types": "./src/runtime/startExpression.ts" + }, + "./compiler/html-document": { + "bun": "./src/compiler/htmlDocument.ts", + "node": "./dist/compiler/htmlDocument.js", + "import": "./src/compiler/htmlDocument.ts", + "types": "./src/compiler/htmlDocument.ts" + }, "./runtime/position-edits": { "bun": "./src/runtime/positionEdits.ts", "node": "./dist/runtime/positionEdits.js", @@ -275,6 +287,14 @@ "import": "./dist/runtime/clipTree.js", "types": "./dist/runtime/clipTree.d.ts" }, + "./runtime/start-expression": { + "import": "./dist/runtime/startExpression.js", + "types": "./dist/runtime/startExpression.d.ts" + }, + "./compiler/html-document": { + "import": "./dist/compiler/htmlDocument.js", + "types": "./dist/compiler/htmlDocument.d.ts" + }, "./runtime/position-edits": { "import": "./dist/runtime/positionEdits.js", "types": "./dist/runtime/positionEdits.d.ts" diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0a527d9a3..5a7a0ae57 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -158,6 +158,7 @@ export { type SubCompositionValidity, type SubCompositionValidityReason, } from "./compiler/subCompositionValidity"; +export { RUNTIME_BOOTSTRAP_ATTR, stripEmbeddedRuntimeScripts } from "./compiler/htmlDocument"; export { queryByAttr } from "./utils/cssSelector"; export { decodeUrlPathVariants } from "./utils/urlPath"; export { parseAnimatedGifMetadata, type AnimatedGifMetadata } from "./media/gif"; diff --git a/packages/sdk/src/engine/model.ts b/packages/sdk/src/engine/model.ts index d05959cd7..f721fd9dc 100644 --- a/packages/sdk/src/engine/model.ts +++ b/packages/sdk/src/engine/model.ts @@ -134,6 +134,17 @@ export function resolveScoped(document: Document, id: string): Element | null { return context as Element; } +/** + * Bare leaf id from a scoped hf-id ("hf-HOST/hf-LEAF" → "hf-LEAF"; a bare id + * passes through unchanged). The live DOM's `data-hf-id` attribute never + * carries the host-chain prefix, so a consumer holding a scopedId (from + * getElements()/getElement()) needs this to query the rendered DOM directly. + */ +export function bareId(scopedId: string): string { + const parts = scopedId.split("/"); + return parts[parts.length - 1] ?? scopedId; +} + /** * Returns true when this element starts a new sub-composition scope — i.e. it * is a host element (has data-composition-file) and is NOT the outerHTML diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 731dc4514..ed9df3df5 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -26,6 +26,8 @@ export { UnsupportedOpError } from "./engine/mutate.js"; export { buildDocument, buildRoots, flatElements } from "./document.js"; +export { isNewHostBoundary, bareId } from "./engine/model.js"; + export { openComposition } from "./session.js"; export type { OpenCompositionOptions } from "./session.js"; diff --git a/packages/sdk/src/session.subcomp.test.ts b/packages/sdk/src/session.subcomp.test.ts index fede50e2b..2b320440f 100644 --- a/packages/sdk/src/session.subcomp.test.ts +++ b/packages/sdk/src/session.subcomp.test.ts @@ -13,7 +13,7 @@ import { describe, it, expect } from "vitest"; import { parseHTML } from "linkedom"; import { ensureHfIds } from "@hyperframes/core/hf-ids"; -import { resolveScoped, findById } from "./engine/model.js"; +import { resolveScoped, findById, isNewHostBoundary, bareId } from "./engine/model.js"; import { parseMutable } from "./engine/model.js"; import { buildRoots, flatElements } from "./document.js"; import { openComposition } from "./session.js"; @@ -531,3 +531,120 @@ describe("scopedId stability across serialize/re-parse", () => { expect(ids1).toEqual(ids2); }); }); + +// ─── 7. isNewHostBoundary ────────────────────────────────────────────────────── + +describe("isNewHostBoundary", () => { + it("is true for a host with no ancestor dcf (top-level sub-comp host)", () => { + const doc = makeDoc( + inlinedHtml(`
`), + ) as unknown as Document; + const host = doc.querySelector('[data-hf-id="hf-host"]') as unknown as Element; + expect(isNewHostBoundary(host)).toBe(true); + }); + + it("is false for an element with no data-composition-file at all", () => { + const doc = makeDoc(inlinedHtml(`
`)) as unknown as Document; + const el = doc.querySelector('[data-hf-id="hf-plain"]') as unknown as Element; + expect(isNewHostBoundary(el)).toBe(false); + }); + + it("is false for the outerHTML innerRoot (same dcf value as its host parent)", () => { + const doc = makeDoc( + inlinedHtml(` +
+
+
+ `), + ) as unknown as Document; + const inner = doc.querySelector('[data-hf-id="hf-inner"]') as unknown as Element; + expect(isNewHostBoundary(inner)).toBe(false); + }); + + it("is true for a nested host with a DIFFERENT dcf from its parent", () => { + const doc = makeDoc( + inlinedHtml(` +
+
+
+ `), + ) as unknown as Document; + const innerHost = doc.querySelector('[data-hf-id="hf-inner-host"]') as unknown as Element; + expect(isNewHostBoundary(innerHost)).toBe(true); + }); +}); + +// ─── 8. bareId ────────────────────────────────────────────────────────────────── + +describe("bareId", () => { + it("returns the leaf segment of a scoped id", () => { + expect(bareId("hf-host/hf-leaf")).toBe("hf-leaf"); + }); + + it("returns a deeply nested id's leaf segment", () => { + expect(bareId("hf-a/hf-b/hf-c")).toBe("hf-c"); + }); + + it("passes a bare id through unchanged", () => { + expect(bareId("hf-solo")).toBe("hf-solo"); + }); +}); + +// ─── 9. getRootElements — no descendant duplication ──────────────────────────── + +describe("getRootElements", () => { + it("excludes descendants that getElements() also lists as top-level entries", async () => { + const html = inlinedHtml(` +
+

Title

+
+

solo

+ `); + const comp = await openComposition(html); + + // getElements() is flat: hf-title appears once nested under hf-panel AND + // once again as its own top-level entry. + const flatIds = comp.getElements().map((e) => e.id); + expect(flatIds).toContain("hf-title"); + expect(flatIds).toContain("hf-panel"); + + // getRootElements() only returns true roots — hf-title is not one, since + // it's hf-panel's descendant. + const rootIds = comp.getRootElements().map((e) => e.id); + expect(rootIds).toEqual(["hf-panel", "hf-solo"]); + expect(comp.getRootElements().find((e) => e.id === "hf-panel")?.children[0]?.id).toBe( + "hf-title", + ); + }); + + it("treats a sub-composition host as a root even though it has descendants", async () => { + const html = inlinedHtml(` +
+

inside

+
+ `); + const comp = await openComposition(html); + expect(comp.getRootElements().map((e) => e.id)).toEqual(["hf-host"]); + }); +}); + +// ─── 10. serialize({ stripRuntime }) ─────────────────────────────────────────── + +describe("serialize({ stripRuntime })", () => { + const RUNTIME_SCRIPT = + ''; + + it("keeps the embedded runtime script by default", async () => { + const html = `${RUNTIME_SCRIPT}
`; + const comp = await openComposition(html); + expect(comp.serialize()).toContain("hyperframe.runtime"); + }); + + it("strips the embedded runtime script when stripRuntime is true", async () => { + const html = `${RUNTIME_SCRIPT}
`; + const comp = await openComposition(html); + const out = comp.serialize({ stripRuntime: true }); + expect(out).not.toContain("hyperframe.runtime"); + expect(out).toContain('data-hf-id="hf-a"'); + }); +}); diff --git a/packages/sdk/src/session.timings.test.ts b/packages/sdk/src/session.timings.test.ts index 0f1907b08..4b9168bc5 100644 --- a/packages/sdk/src/session.timings.test.ts +++ b/packages/sdk/src/session.timings.test.ts @@ -113,6 +113,46 @@ describe("getElementTimings — GSAP labels", () => { }); }); +// ─── getElementTimings — relative data-start references ────────────────────── + +/** "intro" starts at data-start=1 for 3s (ends at 4). "outro" starts 2s after intro ends. */ +const RELATIVE_START_HTML = ` +
+

Intro

+

Outro

+

Right after

+
+`.trim(); + +describe("getElementTimings — relative data-start references", () => { + it("resolves 'ref + offset' against the referenced element's resolved end", async () => { + const comp = await openComposition(RELATIVE_START_HTML); + const timings = comp.getElementTimings(); + + // hf-intro: enterAt=1, exitAt=4 + expect(timings["hf-intro"]).toMatchObject({ enterAt: 1, exitAt: 4 }); + // hf-outro: "hf-intro + 2" = intro's exitAt (4) + 2 = 6 + expect(timings["hf-outro"]).toMatchObject({ enterAt: 6, exitAt: 10 }); + }); + + it("resolves a bare reference (no offset) to the referenced element's exitAt", async () => { + const comp = await openComposition(RELATIVE_START_HTML); + const timings = comp.getElementTimings(); + expect(timings["hf-right-after"]).toMatchObject({ enterAt: 4, exitAt: 5 }); + }); + + it("resolves to 0 (not NaN) when the reference target doesn't exist", async () => { + const html = ` +
+

+
+ `.trim(); + const comp = await openComposition(html); + const timings = comp.getElementTimings(); + expect(timings["hf-orphan"]).toMatchObject({ enterAt: 0, exitAt: 2 }); + }); +}); + // ─── setElementTiming — sparse map + batched dispatch ──────────────────────── describe("setElementTiming", () => { diff --git a/packages/sdk/src/session.ts b/packages/sdk/src/session.ts index cbeb781ec..317fda9b5 100644 --- a/packages/sdk/src/session.ts +++ b/packages/sdk/src/session.ts @@ -36,6 +36,8 @@ import type { ParsedDocument } from "./engine/model.js"; import { applyOp, validateOp, type MutationResult } from "./engine/mutate.js"; import { getGsapScript, resolveScoped } from "./engine/model.js"; import { extractGsapLabels } from "@hyperframes/core/gsap-parser-acorn"; +import { stripEmbeddedRuntimeScripts } from "@hyperframes/core/compiler/html-document"; +import { parseStartExpression } from "@hyperframes/core/runtime/start-expression"; import { serializeDocument } from "./engine/serialize.js"; import { applyPatchesToDocument, applyOverrideSet } from "./engine/apply-patches.js"; import { buildPatchEvent, pathToKey } from "./engine/patches.js"; @@ -174,33 +176,61 @@ class CompositionImpl implements Composition { this._gsapLabelCache = script ? { script, labels: allLabels } : null; } + // Resolve a `data-start` that's a relative-timing REFERENCE ("intro", "intro + 2" — + // parseStartExpression's grammar) into an absolute second, recursively against the + // referenced element's own resolved start + duration. A plain numeric data-start keeps + // the old parseFloat path unchanged — this only touches the case that used to silently + // resolve to 0 (parseFloat("intro + 2") is NaN). Node-safe static counterpart of the + // runtime's own resolver (runtime/startResolver.ts): no live GSAP timeline to fall back + // on, so an unauthored sub-composition duration still resolves to 0, same as before. + const startCache = new Map(); + const visiting = new Set(); + const resolveStart = (el: Element): number => { + const cached = startCache.get(el); + if (cached !== undefined) return cached; + if (visiting.has(el)) return 0; // reference cycle — fail safe, don't loop + visiting.add(el); + let resolved: number; + try { + const startStr = el.getAttribute("data-start"); + const expr = parseStartExpression(startStr); + if (expr?.kind === "reference") { + const target = resolveScoped(this.parsed.document, expr.refId); + resolved = target + ? Math.max(0, resolveStart(target) + (resolveDuration(target) ?? 0) + expr.offset) + : 0; + } else { + resolved = startStr !== null ? parseFloat(startStr) : 0; + } + } finally { + visiting.delete(el); + } + const finite = Number.isFinite(resolved) ? resolved : 0; + startCache.set(el, finite); + return finite; + }; + // Same preference as handleSetTiming: prefer data-duration, fall back to end - start. + const resolveDuration = (el: Element): number | null => { + const durationStr = el.getAttribute("data-duration"); + const durationAttr = durationStr !== null ? parseFloat(durationStr) : null; + if (durationAttr !== null && Number.isFinite(durationAttr)) return durationAttr; + const endStr = el.getAttribute("data-end"); + const endAttr = endStr !== null ? parseFloat(endStr) : null; + if (endAttr !== null && Number.isFinite(endAttr)) return endAttr - resolveStart(el); + return null; + }; + const result: Record = {}; const elements = this.getElements(); for (const el of elements) { const domEl = resolveScoped(this.parsed.document, el.scopedId); if (!domEl) continue; - const startStr = domEl.getAttribute("data-start"); - const endStr = domEl.getAttribute("data-end"); - const durationStr = domEl.getAttribute("data-duration"); + const enterAt = resolveStart(domEl); + const duration = resolveDuration(domEl); + if (duration === null) continue; // no timing info — skip non-timed elements - // Same preference as handleSetTiming: prefer data-duration, fall back to end - start. - const start = startStr !== null ? parseFloat(startStr) : 0; - const durationAttr = durationStr !== null ? parseFloat(durationStr) : null; - const endAttr = endStr !== null ? parseFloat(endStr) : null; - - let duration: number; - if (durationAttr !== null && Number.isFinite(durationAttr)) { - duration = durationAttr; - } else if (endAttr !== null && Number.isFinite(endAttr)) { - duration = endAttr - start; - } else { - // No timing info — skip non-timed elements. - continue; - } - - const enterAt = Number.isFinite(start) ? start : 0; - const exitAt = enterAt + (Number.isFinite(duration) ? duration : 0); + const exitAt = enterAt + duration; // Labels whose position falls within [enterAt, exitAt] (end-inclusive: a // label exactly at exitAt is treated as within the element's window). @@ -307,6 +337,18 @@ class CompositionImpl implements Composition { return [...this.elementsCache]; } + /** + * Top-level elements only (each still carrying its full descendant subtree via + * `.children`) — unlike `getElements()`, no element appears twice. Consumers building a + * tree view (a layer panel) want this, not `getElements()`: that method's flat list + * includes every descendant a second time as its own top-level entry, since each + * snapshot in it still carries its children. `buildRoots` already computes true roots + * internally for `getElements()` to flatten — this just returns them unflattened. + */ + getRootElements(): ElementSnapshot[] { + return buildRoots(this.parsed.document); + } + getElement(id: HfId): ElementSnapshot | null { // Accept both bare ids (top-level) and scoped ids (sub-composition elements). // Match by scopedId first (canonical); bare-id fallback keeps top-level compat @@ -567,8 +609,13 @@ class CompositionImpl implements Composition { // ── Serialization ──────────────────────────────────────────────────────────── - serialize(): string { - return serializeDocument(this.parsed); + serialize(opts?: { stripRuntime?: boolean }): string { + const html = serializeDocument(this.parsed); + // Newer agent-generated compositions embed hyperframe.runtime.iife.js in their own + // HTML. A host driving its own clock (an editing iframe) must not let that runtime + // self-init — it races the host's first seek and resets the timeline to t=0. Opt-in + // (default false) since a host playing the composition normally wants the runtime. + return opts?.stripRuntime ? stripEmbeddedRuntimeScripts(html) : html; } // ── T3 embedded-mode extras ────────────────────────────────────────────────── diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index 59476fae3..83b875af0 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -461,6 +461,8 @@ export interface Composition { // ── Query API (F1) ───────────────────────────────────────────────────────── getElements(): ElementSnapshot[]; + /** Top-level elements only, each carrying its full subtree — no id appears twice. */ + getRootElements(): ElementSnapshot[]; getElement(id: HfId): ElementSnapshot | null; find(query: FindQuery): string[]; /**