From 8747039cb13d6d648f4c2cb74b4f840a24e67c0a Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 10 Jun 2026 13:53:08 -0700 Subject: [PATCH 1/5] =?UTF-8?q?feat(sdk):=20session=20API,=20optional=20hi?= =?UTF-8?q?story=20+=20persist-queue,=20adapters=20=E2=80=94=20Phase=203a?= =?UTF-8?q?=20complete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/sdk/examples/headless-agent.ts | 206 +++++++++ packages/sdk/examples/react-embed.ts | 148 +++++++ packages/sdk/examples/vanilla-editor.ts | 180 ++++++++ packages/sdk/src/adapters/fs.ts | 46 ++ packages/sdk/src/adapters/headless.ts | 24 ++ packages/sdk/src/adapters/memory.ts | 62 +++ .../adapters/persistAdapter.contract.test.ts | 128 ++++++ packages/sdk/src/document.ts | 190 +++++++++ packages/sdk/src/history.ts | 127 ++++++ packages/sdk/src/persist-queue.ts | 80 ++++ packages/sdk/src/session.ts | 396 ++++++++++++++++++ 11 files changed, 1587 insertions(+) create mode 100644 packages/sdk/examples/headless-agent.ts create mode 100644 packages/sdk/examples/react-embed.ts create mode 100644 packages/sdk/examples/vanilla-editor.ts create mode 100644 packages/sdk/src/adapters/fs.ts create mode 100644 packages/sdk/src/adapters/headless.ts create mode 100644 packages/sdk/src/adapters/memory.ts create mode 100644 packages/sdk/src/adapters/persistAdapter.contract.test.ts create mode 100644 packages/sdk/src/document.ts create mode 100644 packages/sdk/src/history.ts create mode 100644 packages/sdk/src/persist-queue.ts create mode 100644 packages/sdk/src/session.ts diff --git a/packages/sdk/examples/headless-agent.ts b/packages/sdk/examples/headless-agent.ts new file mode 100644 index 000000000..af0d7052a --- /dev/null +++ b/packages/sdk/examples/headless-agent.ts @@ -0,0 +1,206 @@ +/** + * Archetype (c) — Headless agent script + * + * Shows: no browser, no persist adapter, no preview — pure editing engine. + * Agents: batch restyling, localization, A/B variants, programmatic animation. + * Explicit-id ops via query API — no selection, no mouse events. + * + * F1 payoff: headless is possible BECAUSE ops have explicit targets. + * Selection-implicit ops (old R0) would break here — no UI → no selection. + */ + +import { openComposition } from "../src/index.js"; +import type { ElementSnapshot } from "../src/index.js"; + +// ── Localization agent ──────────────────────────────────────────────────────── +// Rewrites all text elements to a new locale. No browser, no preview. + +export async function localize(html: string, translations: Map): Promise { + const comp = await openComposition(html); + + const textElements = comp.find({ tag: "div" }); + + comp.batch(() => { + for (const id of textElements) { + const el = comp.getElement(id); + if (!el?.text) continue; + const translated = translations.get(el.text); + if (translated) comp.setText(id, translated); + } + }); + + return comp.serialize(); +} + +// ── Brand restyle agent ─────────────────────────────────────────────────────── +// Apply brand colors to all elements with a matching class name. + +export async function applyBrandColors( + html: string, + brandPrimary: string, + brandSecondary: string, +): Promise { + const comp = await openComposition(html); + + // Query: find elements by attribute pattern + const brandColorEls = comp + .getElements() + .filter((el) => el.attributes["data-brand-role"] === "primary"); + const brandSecondaryEls = comp + .getElements() + .filter((el) => el.attributes["data-brand-role"] === "secondary"); + + comp.batch(() => { + for (const el of brandColorEls) { + comp.setStyle(el.id, { color: brandPrimary }); + } + for (const el of brandSecondaryEls) { + comp.setStyle(el.id, { color: brandSecondary }); + } + }); + + return comp.serialize(); +} + +// ── A/B variant agent ───────────────────────────────────────────────────────── +// Produce two HTML variants from one template. + +export async function createABVariants( + html: string, + variantB: { headlineId: string; text: string; color: string }, +): Promise<{ variantA: string; variantB: string }> { + const compA = await openComposition(html); + const variantAHtml = compA.serialize(); + compA.dispose(); + + const compB = await openComposition(html); + compB.setText(variantB.headlineId, variantB.text); + compB.setStyle(variantB.headlineId, { color: variantB.color }); + const variantBHtml = compB.serialize(); + compB.dispose(); + + return { variantA: variantAHtml, variantB: variantBHtml }; +} + +// ── Asset swap agent ────────────────────────────────────────────────────────── +// F3: setAttribute handles img src, href, alt — the full attribute space. + +export async function swapAssets( + html: string, + swaps: Array<{ id: string; src: string; alt?: string }>, +): Promise { + const comp = await openComposition(html); + + comp.batch(() => { + for (const swap of swaps) { + comp.setAttribute(swap.id, "src", swap.src); + if (swap.alt !== undefined) { + comp.setAttribute(swap.id, "alt", swap.alt); + } + } + }); + + return comp.serialize(); +} + +// ── Batch GSAP animation agent ──────────────────────────────────────────────── +// Add staggered entrance animations to all text elements. + +export async function addStaggeredEntrance(html: string, staggerDelay = 0.15): Promise { + const comp = await openComposition(html); + + const textEls = comp.find({ tag: "div" }); + + comp.batch(() => { + textEls.forEach((id, i) => { + comp.addGsapTween(id, { + method: "from", + position: i * staggerDelay, + duration: 0.5, + ease: "power3.out", + fromProperties: { opacity: 0, y: 30 }, + }); + }); + }); + + return comp.serialize(); +} + +// ── Composition metadata normalization ──────────────────────────────────────── + +export async function normalizeToPortrait(html: string): Promise { + const comp = await openComposition(html); + comp.dispatch({ type: "setCompositionMetadata", width: 1080, height: 1920 }); + return comp.serialize(); +} + +// ── Variable override agent ─────────────────────────────────────────────────── +// Apply a brand kit as composition variable overrides. + +export async function applyVariableKit( + html: string, + kit: Record, +): Promise { + const comp = await openComposition(html); + + comp.batch(() => { + for (const [id, value] of Object.entries(kit)) { + comp.setVariableValue(id, value); + } + }); + + return comp.serialize(); +} + +// ── Inspection utility ──────────────────────────────────────────────────────── +// Agents need to discover what's in a composition before editing. + +export async function inspectComposition(html: string): Promise<{ + elementCount: number; + textElements: ElementSnapshot[]; + imageElements: ElementSnapshot[]; + ids: string[]; +}> { + const comp = await openComposition(html); + + const all = comp.getElements(); + const textElements = all.filter((el) => ["div", "p", "h1", "h2", "h3", "span"].includes(el.tag)); + const imageElements = all.filter((el) => el.tag === "img"); + + comp.dispose(); + + return { + elementCount: all.length, + textElements, + imageElements, + ids: all.map((el) => el.id), + }; +} + +// ── Timing normalization agent ──────────────────────────────────────────────── + +export async function normalizeTiming(html: string, totalDuration: number): Promise { + const comp = await openComposition(html); + + const timedEls = comp.getElements().filter((el) => el.start !== null && el.duration !== null); + + const lastEnd = timedEls.reduce( + (max, el) => Math.max(max, (el.start ?? 0) + (el.duration ?? 0)), + 0, + ); + if (lastEnd === 0) return comp.serialize(); + + const scale = totalDuration / lastEnd; + + comp.batch(() => { + for (const el of timedEls) { + comp.setTiming(el.id, { + start: Math.round((el.start ?? 0) * scale * 100) / 100, + duration: Math.round((el.duration ?? 0) * scale * 100) / 100, + }); + } + comp.dispatch({ type: "setCompositionMetadata", duration: totalDuration }); + }); + + return comp.serialize(); +} diff --git a/packages/sdk/examples/react-embed.ts b/packages/sdk/examples/react-embed.ts new file mode 100644 index 000000000..8ba71bf78 --- /dev/null +++ b/packages/sdk/examples/react-embed.ts @@ -0,0 +1,148 @@ +/** + * Archetype (a) — React app embedding the SDK (T1 standalone) + * + * Shows: openComposition, event subscription, typed methods, selection sugar, + * batch + brand kit, useSyncExternalStore pattern, undo/redo, export. + * + * Note: JSX/React not imported here to keep this file framework-agnostic .ts. + * In a real React app: wrap createEditorSession in useEffect, subscribe with + * useSyncExternalStore (see comment blocks below). + */ + +import { openComposition, ORIGIN_APPLY_PATCHES } from "../src/index.js"; +import { createMemoryAdapter } from "../src/adapters/memory.js"; +import type { Composition, ElementSnapshot } from "../src/index.js"; + +// ── Session factory ─────────────────────────────────────────────────────────── +// Typically called once in useEffect(() => { createEditorSession(html).then(setComp) }, []) + +export async function createEditorSession(html: string): Promise { + const persist = createMemoryAdapter(); + + const comp = await openComposition(html, { persist }); + + // Persist failures surface as events, never fatal exceptions. + comp.on("persist:error", ({ error }) => { + console.error(`Auto-save failed: ${error.message}`); + // In a real app: show a toast notification + }); + + return comp; +} + +// ── useSyncExternalStore integration ───────────────────────────────────────── +// React 18+ pattern: +// +// const selection = useSyncExternalStore( +// (cb) => comp.on('selectionchange', cb), +// () => comp.getSelection(), +// ) +// +// Imperative equivalent for non-React consumers: + +export function subscribeToSelection( + comp: Composition, + onChange: (ids: string[]) => void, +): () => void { + return comp.on("selectionchange", onChange); +} + +// ── Property panel bindings ─────────────────────────────────────────────────── + +export function applyStyle(comp: Composition, id: string, prop: string, value: string): void { + // F1: explicit target — panel holds the id when rendering the current element + comp.setStyle(id, { [prop]: value }); +} + +export function applyFontSize(comp: Composition, id: string, px: number): void { + comp.setStyle(id, { fontSize: `${px}px` }); +} + +export function applyTextContent(comp: Composition, id: string, value: string): void { + comp.setText(id, value); +} + +// Selection sugar — resolves getSelection() → explicit ops at call time. +// Equivalent to: ids = comp.getSelection(); comp.setStyle(ids, {...}) +export function applyColorToSelection(comp: Composition, color: string): void { + comp.selection().setStyle({ color }); +} + +// ── Brand kit (batch) ───────────────────────────────────────────────────────── +// One undo entry, one persist write, one change event. + +export function applyBrandKit(comp: Composition, kit: Record): void { + comp.batch(() => { + for (const [variableId, value] of Object.entries(kit)) { + comp.setVariableValue(variableId, value); + } + }); +} + +// ── Timeline drag ───────────────────────────────────────────────────────────── + +export function onClipDrag(comp: Composition, id: string, start: number, duration: number): void { + comp.setTiming(id, { start, duration }); +} + +// ── GSAP animation panel ────────────────────────────────────────────────────── + +export function addBounceIn(comp: Composition, targetId: string): string { + return comp.addGsapTween(targetId, { + method: "from", + position: 0, + duration: 0.5, + ease: "bounce.out", + fromProperties: { y: 40, opacity: 0 }, + }); +} + +export function updateEase(comp: Composition, animationId: string, ease: string): void { + comp.setGsapTween(animationId, { ease }); +} + +// ── Undo / redo ─────────────────────────────────────────────────────────────── + +export function undo(comp: Composition): void { + comp.undo(); +} + +export function redo(comp: Composition): void { + comp.redo(); +} + +// ── T3 host undo integration (embedded mode) ───────────────────────────────── +// When the SDK is embedded in a host with its own undo timeline: + +export type HostHistoryEntry = + | { kind: "sdk"; patches: ReturnType; inversePatches: unknown[] } + | { kind: "native"; data: unknown }; + +export function setupHostUndo( + comp: Composition, + pushToHostHistory: (entry: HostHistoryEntry) => void, +): () => void { + return comp.on("patch", ({ patches, inversePatches, origin }) => { + // Origin guard: skip re-emissions from applyPatches to avoid undo loops (F4) + if (origin === ORIGIN_APPLY_PATCHES) return; + + pushToHostHistory({ + kind: "sdk", + patches: patches as unknown as ReturnType, + inversePatches: [...inversePatches], + }); + }); +} + +// ── Export ──────────────────────────────────────────────────────────────────── + +export function exportHtml(comp: Composition): string { + return comp.serialize(); +} + +// ── Query API usage ─────────────────────────────────────────────────────────── + +export function findTextElements(comp: Composition): ElementSnapshot[] { + const ids = comp.find({ tag: "div" }); + return ids.map((id) => comp.getElement(id)).filter((el): el is ElementSnapshot => el !== null); +} diff --git a/packages/sdk/examples/vanilla-editor.ts b/packages/sdk/examples/vanilla-editor.ts new file mode 100644 index 000000000..5f76a9e79 --- /dev/null +++ b/packages/sdk/examples/vanilla-editor.ts @@ -0,0 +1,180 @@ +/** + * Archetype (b) — Vanilla standalone editor (T1) + * + * Shows: openComposition with fs adapter pattern, typed methods (the docs page one surface), + * element handle, batch, dispatch (advanced layer), slider-burst coalescing intent, + * sub-composition editing intent, timeline label ops. + * + * This is the "zero-framework" path: plain TypeScript, no React, no Vue. + * Target: a tools developer building a custom editor UI from scratch. + */ + +import { openComposition } from "../src/index.js"; +import { createMemoryAdapter } from "../src/adapters/memory.js"; +import type { Composition, GsapTweenSpec } from "../src/index.js"; + +// ── Initialize ──────────────────────────────────────────────────────────────── + +export async function initEditor(html: string): Promise { + // Use createFsAdapter({ root: projectDir }) in production: + // import { createFsAdapter } from '@hyperframes/sdk/adapters/fs' + const persist = createMemoryAdapter(); + + const comp = await openComposition(html, { + persist, + coalesceMs: 300, + }); + + comp.on("persist:error", ({ error }) => { + showError(`Auto-save failed: ${error.message}${error.hint ? ` — ${error.hint}` : ""}`); + }); + + return comp; +} + +// ── Property panel — typed method layer (F10 docs page one) ────────────────── + +export function setColor(comp: Composition, id: string, color: string): void { + comp.setStyle(id, { color }); +} + +export function setFontFamily(comp: Composition, id: string, family: string): void { + comp.setStyle(id, { fontFamily: family }); +} + +export function swapImage(comp: Composition, id: string, src: string): void { + // F3: setAttribute closes the attribute space — handles img src, href, alt, data-*, ARIA + comp.setAttribute(id, "src", src); +} + +export function setAltText(comp: Composition, id: string, alt: string): void { + comp.setAttribute(id, "alt", alt); +} + +export function removeElement(comp: Composition, id: string): void { + comp.removeElement(id); + // Inverse patch carries full serialized subtree — undo restores it. +} + +// ── Element handle pattern ──────────────────────────────────────────────────── +// comp.element(id) — curried handle, no stale-ref hazard + +export function editHeadline(comp: Composition, headlineId: string): void { + const h = comp.element(headlineId); + h.setText("New headline"); + h.setStyle({ color: "#FFD60A", fontSize: "96px" }); + h.setTiming({ start: 0.5, duration: 3 }); +} + +// ── Slider burst (rapid dispatch — coalesced into one undo entry) ───────────── + +export function onFontSizeSlider(comp: Composition, id: string, px: number): void { + // Each input event dispatches setStyle. History coalesces: same op + same target + // within coalesceMs → one undo entry (forward keeps latest, inverse keeps first prev). + // Persist queue writes once when the burst settles. + comp.setStyle(id, { fontSize: `${px}px` }); +} + +// ── Batch ───────────────────────────────────────────────────────────────────── +// One undo entry, one persist write, one subscriber notification. + +export function applyTextPreset( + comp: Composition, + id: string, + preset: { fontSize: string; color: string; fontFamily: string }, +): void { + comp.batch(() => { + comp.setStyle(id, { + fontSize: preset.fontSize, + color: preset.color, + fontFamily: preset.fontFamily, + }); + }); +} + +// ── dispatch() — advanced layer for agents / automation ────────────────────── +// Typed methods are sugar; dispatch() remains public for data-shaped op emission. + +export function applyOpFromJson(comp: Composition, opJson: unknown): void { + // Agents or automation scripts that emit JSON op objects use dispatch directly. + comp.dispatch(opJson as Parameters[0]); +} + +// ── GSAP operations ─────────────────────────────────────────────────────────── + +export function addFadeIn(comp: Composition, targetId: string, delay = 0): string { + return comp.addGsapTween(targetId, { + method: "from", + position: delay, + duration: 0.4, + ease: "power2.out", + fromProperties: { opacity: 0 }, + }); +} + +export function addBounce( + comp: Composition, + targetId: string, + overrides?: Partial, +): string { + return comp.addGsapTween(targetId, { + method: "from", + position: 0, + duration: 0.6, + ease: "bounce.out", + fromProperties: { y: 60, opacity: 0 }, + ...overrides, + }); +} + +// Keyframe editing (addGsapKeyframe / removeGsapKeyframe — v1, promoted 2026-06-09): +export function insertKeyframe(comp: Composition, animationId: string, position: number): void { + comp.dispatch({ + type: "addGsapKeyframe", + animationId, + position, + value: { opacity: 1 }, + }); +} + +// Timeline labels +export function addLabel(comp: Composition, name: string, position: number): void { + comp.dispatch({ type: "addLabel", name, position }); +} + +// ── Composition metadata ────────────────────────────────────────────────────── + +export function resizeComposition( + comp: Composition, + width: number, + height: number, + duration: number, +): void { + comp.dispatch({ type: "setCompositionMetadata", width, height, duration }); +} + +// ── Export ──────────────────────────────────────────────────────────────────── + +export function exportComposition(comp: Composition): string { + return comp.serialize(); +} + +// ── Query API ───────────────────────────────────────────────────────────────── + +export function listAllElementIds(comp: Composition): string[] { + return comp.getElements().map((el) => el.id); +} + +export function findByText(comp: Composition, text: string): string[] { + return comp.find({ text }); +} + +// ── Lifecycle ───────────────────────────────────────────────────────────────── + +export function cleanup(comp: Composition): void { + comp.dispose(); +} + +function showError(msg: string): void { + console.error(msg); +} diff --git a/packages/sdk/src/adapters/fs.ts b/packages/sdk/src/adapters/fs.ts new file mode 100644 index 000000000..1a92149c2 --- /dev/null +++ b/packages/sdk/src/adapters/fs.ts @@ -0,0 +1,46 @@ +import type { PersistAdapter, PersistVersionEntry } from "./types.js"; +import type { PersistErrorEvent } from "../types.js"; + +export interface FsAdapterOptions { + /** Root directory for composition files */ + root: string; +} + +// Phase 4 — fs adapter stub. Full implementation in SDK Phase 4 (adapters stage). +// Uses Node.js fs/promises; not browser-safe (must be conditionally imported by consumers). + +class FsAdapter implements PersistAdapter { + private readonly root: string; + + constructor(opts: FsAdapterOptions) { + this.root = opts.root; + } + + async read(_path: string): Promise { + throw new Error("FsAdapter: Phase 4 — not yet implemented"); + } + + async write(_path: string, _content: string): Promise { + throw new Error("FsAdapter: Phase 4 — not yet implemented"); + } + + async flush(): Promise { + throw new Error("FsAdapter: Phase 4 — not yet implemented"); + } + + async listVersions(_path: string): Promise { + throw new Error("FsAdapter: Phase 4 — not yet implemented"); + } + + async loadFrom(_path: string, _versionKey: string): Promise { + throw new Error("FsAdapter: Phase 4 — not yet implemented"); + } + + on(_event: "persist:error", _handler: (e: PersistErrorEvent) => void): () => void { + return () => {}; + } +} + +export function createFsAdapter(opts: FsAdapterOptions): PersistAdapter { + return new FsAdapter(opts); +} diff --git a/packages/sdk/src/adapters/headless.ts b/packages/sdk/src/adapters/headless.ts new file mode 100644 index 000000000..ed1f09692 --- /dev/null +++ b/packages/sdk/src/adapters/headless.ts @@ -0,0 +1,24 @@ +import type { PreviewAdapter, ElementAtPointResult, DraftProps } from "./types.js"; + +/** Null PreviewAdapter for headless use (agents, CI, server-side rendering). */ +class HeadlessPreviewAdapter implements PreviewAdapter { + elementAtPoint(_x: number, _y: number, _opts?: { atTime?: number }): ElementAtPointResult | null { + return null; + } + + applyDraft(_id: string, _props: DraftProps): void {} + + commitPreview(): void {} + + cancelPreview(): void {} + + select(_ids: string[], _opts?: { additive?: boolean }): void {} + + on(_event: "selection", _handler: (ids: string[]) => void): () => void { + return () => {}; + } +} + +export function createHeadlessAdapter(): PreviewAdapter { + return new HeadlessPreviewAdapter(); +} diff --git a/packages/sdk/src/adapters/memory.ts b/packages/sdk/src/adapters/memory.ts new file mode 100644 index 000000000..2624db153 --- /dev/null +++ b/packages/sdk/src/adapters/memory.ts @@ -0,0 +1,62 @@ +import type { PersistAdapter, PersistVersionEntry } from "./types.js"; +import type { PersistErrorEvent } from "../types.js"; + +class MemoryAdapter implements PersistAdapter { + private readonly store = new Map(); + private readonly history = new Map(); + private readonly errorListeners: Array<(e: PersistErrorEvent) => void> = []; + private versionCounter = 0; + private faultMessage: string | null = null; + + async read(path: string): Promise { + return this.store.get(path); + } + + async write(path: string, content: string): Promise { + if (this.faultMessage !== null) { + const msg = this.faultMessage; + this.faultMessage = null; + this.errorListeners.forEach((l) => l({ error: { message: msg } })); + return; + } + this.store.set(path, content); + const hist = this.history.get(path) ?? []; + const entry: PersistVersionEntry = { + key: `v${++this.versionCounter}`, + content, + }; + hist.unshift(entry); + this.history.set(path, hist); + } + + async flush(): Promise { + // Memory adapter writes are synchronous — nothing to drain. + } + + async listVersions(path: string): Promise { + return [...(this.history.get(path) ?? [])]; + } + + async loadFrom(path: string, versionKey: string): Promise { + const hist = this.history.get(path) ?? []; + return hist.find((v) => v.key === versionKey)?.content; + } + + on(event: "persist:error", handler: (e: PersistErrorEvent) => void): () => void { + if (event !== "persist:error") return () => {}; + this.errorListeners.push(handler); + return () => { + const idx = this.errorListeners.indexOf(handler); + if (idx !== -1) this.errorListeners.splice(idx, 1); + }; + } + + /** Test helper — next write fires persist:error instead of committing */ + injectFault(message: string): void { + this.faultMessage = message; + } +} + +export function createMemoryAdapter(): PersistAdapter & { injectFault(message: string): void } { + return new MemoryAdapter(); +} diff --git a/packages/sdk/src/adapters/persistAdapter.contract.test.ts b/packages/sdk/src/adapters/persistAdapter.contract.test.ts new file mode 100644 index 000000000..d03e68930 --- /dev/null +++ b/packages/sdk/src/adapters/persistAdapter.contract.test.ts @@ -0,0 +1,128 @@ +/** + * T13 — PersistAdapter contract suite + * + * Parameterized over adapter implementations. Every adapter (memory, fs, S3, HTTP) + * runs the same suite automatically — write once, protect all. + * + * Run against the memory adapter immediately; future implementations: + * runPersistAdapterContract("fs", () => createFsAdapter({ root: tmpDir })) + * runPersistAdapterContract("s3", () => createS3Adapter({ bucket, prefix })) + */ + +import { describe, it, expect, vi } from "vitest"; +import { createMemoryAdapter } from "./memory.js"; +import type { PersistAdapter } from "./types.js"; + +export function runPersistAdapterContract( + label: string, + createAdapter: () => PersistAdapter, +): void { + describe(`PersistAdapter contract — ${label}`, () => { + it("read returns undefined for a path never written", async () => { + const adapter = createAdapter(); + expect(await adapter.read("missing.html")).toBeUndefined(); + }); + + it("write then read returns the written content", async () => { + const adapter = createAdapter(); + await adapter.write("comp.html", ""); + expect(await adapter.read("comp.html")).toBe(""); + }); + + it("second write overwrites the first", async () => { + const adapter = createAdapter(); + await adapter.write("comp.html", "v1"); + await adapter.write("comp.html", "v2"); + expect(await adapter.read("comp.html")).toBe("v2"); + }); + + it("flush() returns after any pending writes are committed", async () => { + const adapter = createAdapter(); + // Write without awaiting to exercise the queue path + void adapter.write("comp.html", "queued"); + await adapter.flush(); + expect(await adapter.read("comp.html")).toBe("queued"); + }); + + it("listVersions returns entries in reverse-chronological order", async () => { + const adapter = createAdapter(); + await adapter.write("comp.html", "v1"); + await adapter.write("comp.html", "v2"); + await adapter.write("comp.html", "v3"); + const versions = await adapter.listVersions("comp.html"); + expect(versions.length).toBeGreaterThanOrEqual(3); + // Newest first + expect(versions[0]?.content).toBe("v3"); + expect(versions[versions.length - 1]?.content).toBe("v1"); + }); + + it("loadFrom restores the model to that version's content", async () => { + const adapter = createAdapter(); + await adapter.write("comp.html", "v1"); + const versions = await adapter.listVersions("comp.html"); + const firstKey = versions[versions.length - 1]?.key; + expect(firstKey).toBeDefined(); + await adapter.write("comp.html", "v2"); + const restored = await adapter.loadFrom("comp.html", firstKey!); + expect(restored).toBe("v1"); + }); + + it("listVersions returns empty array for a path never written", async () => { + const adapter = createAdapter(); + expect(await adapter.listVersions("missing.html")).toEqual([]); + }); + + it("loadFrom returns undefined for an unknown version key", async () => { + const adapter = createAdapter(); + await adapter.write("comp.html", "content"); + expect(await adapter.loadFrom("comp.html", "nonexistent-key")).toBeUndefined(); + }); + + it("on('persist:error') fires when a write fails; error is not thrown", async () => { + // This test uses the injectFault() test helper if available. + // For adapters without fault injection, skip with a note. + const adapter = createAdapter(); + const hasInjectFault = + "injectFault" in adapter && + typeof (adapter as { injectFault: unknown }).injectFault === "function"; + + if (!hasInjectFault) { + // Adapter does not expose fault injection — skip execution + // (test still runs to document the contract; real adapters must implement this) + return; + } + + const onError = vi.fn(); + adapter.on("persist:error", onError); + (adapter as { injectFault(m: string): void }).injectFault("network error"); + + await adapter.write("comp.html", "content"); + + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ + error: expect.objectContaining({ message: "network error" }), + }), + ); + }); + + it("unsubscribe returned by on() removes the listener", async () => { + const adapter = createAdapter(); + const onError = vi.fn(); + const unsub = adapter.on("persist:error", onError); + unsub(); + + // Fire an error if possible + if ( + "injectFault" in adapter && + typeof (adapter as { injectFault: unknown }).injectFault === "function" + ) { + (adapter as { injectFault(m: string): void }).injectFault("err"); + await adapter.write("comp.html", "x"); + expect(onError).not.toHaveBeenCalled(); + } + }); + }); +} + +// Run the suite against the memory adapter immediately +runPersistAdapterContract("memory", createMemoryAdapter); diff --git a/packages/sdk/src/document.ts b/packages/sdk/src/document.ts new file mode 100644 index 000000000..5f4692e93 --- /dev/null +++ b/packages/sdk/src/document.ts @@ -0,0 +1,190 @@ +/** + * 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/core/hf-ids"; +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", +]); + +// fallow-ignore-next-line complexity +function parseInlineStyles(styleAttr: string): Record { + const result: Record = {}; + for (const decl of styleAttr.split(";")) { + const idx = decl.indexOf(":"); + if (idx === -1) continue; + const prop = decl.slice(0, idx).trim(); + const value = decl.slice(idx + 1).trim(); + if (!prop || !value) continue; + // Convert kebab-case → camelCase to match CSSStyleDeclaration convention + const camel = prop.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase()); + result[camel] = value; + } + return result; +} + +function ownText(el: Element): string | null { + let text = ""; + el.childNodes.forEach((n) => { + if (n.nodeType === 3) text += (n as Text).nodeValue ?? ""; + }); + const trimmed = text.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +// fallow-ignore-next-line complexity +function buildElement(el: Element): 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 + + const styleAttr = (el as HTMLElement).getAttribute?.("style") ?? ""; + const inlineStyles = parseInlineStyles(styleAttr); + + const classAttr = el.getAttribute("class") ?? ""; + const classNames = classAttr + .split(/\s+/) + .map((c) => c.trim()) + .filter(Boolean); + + const attributes: Record = {}; + 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); + if (built) children.push(built); + } + + return { + id, + tag, + children, + inlineStyles, + classNames, + attributes, + text: ownText(el), + start, + duration, + trackIndex, + animationIds: [], + }; +} + +// fallow-ignore-next-line complexity +function extractGsapScript(doc: Document): string | null { + // GSAP script is the first