mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +00:00
feat(sdk): scaffold @hyperframes/sdk — engine layer (model, RFC 6902 patches, mutate, apply-patches) (#1324)
* feat(sdk): scaffold @hyperframes/sdk — engine layer (model, RFC 6902 patches, mutate, apply-patches) * fix(sdk): make engine-layer PR self-contained — trim index.ts, guard indexed access - index.ts no longer exports document/session/history/persist-queue (those modules land in the next stacked PR); branch now typechecks standalone - setOwnText: optional-chain children[i] access (TS2532 under noUncheckedIndexedAccess) - fallow suppressions for buildPatchEvent + adapters/types.ts — consumers arrive in #1325 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sdk): fail loudly on Phase 3b ops; add sdk to root build pipeline - applyOp throws UnsupportedOpError (code E_UNSUPPORTED_OP) for the 9 parser-backed ops instead of silently no-opping — callers must never believe an animation edit succeeded when nothing was mutated - validateOp returns false for Phase 3b ops so can() feature-detects - root package.json build filter now includes @hyperframes/sdk (package is dist-only; top-level build previously produced no SDK artifacts). publish.yml intentionally NOT updated — sdk stays unpublished until Phase 3 completes. Adversarial-review findings F3 + F4. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sdk): cross-realm origin sentinel, dual width/height channel, contract docs Round-2 review (Rames/Miguel) on the engine layer: - ORIGIN_APPLY_PATCHES: unique symbol → namespaced string ('@hyperframes/sdk:applyPatches'). Symbols are realm-local — they don't survive postMessage/structured-clone, which T3 embedded hosts may forward patch events across. Namespaced string keeps collision risk negligible. - setCompositionMetadata width/height: runtime treats data-width/data-height as a forced override of inline style (init.ts applyCompositionSizing). Style is always written; the data-* attr is updated when already present so the edit isn't clobbered on load. Absent attrs stay absent — inverses stay exact. Mirrored in the patch applier; 3 new tests. - JsonPatchOp documented as the emit-only RFC 6902 subset (add/remove/replace); applier header notes move/copy/test are ignored. - SdkDocument.html documented as a build-time snapshot (serialize() is the live state). - patches.ts path-grammar comment fixed: timing/{start|end|trackIndex}. NOT changed (with reasons, see PR reply): moveElement left/top matches Studio's own inline-style commit convention (sourcePatcher); package version follows the repo-wide single-version policy. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sdk): moveElement writes data-x/data-y, not left/top CSS HF elements use data-x/data-y for positioning (read by htmlParser.ts, emitted by hyperframes generator). CSS left/top is not the runtime convention. Adds inverse round-trip test for prior position restore. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: update bun.lock after sdk package registration Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
2c64f99694
commit
22bb6737c5
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Mutable document — linkedom Document wrapper for Phase 3 editing.
|
||||
*
|
||||
* The linkedom Document IS the mutable backing store. All dispatch mutations
|
||||
* go here. serialize() walks the live DOM; no separate mutable tree to sync.
|
||||
*/
|
||||
|
||||
import { parseHTML } from "linkedom";
|
||||
import { ensureHfIds } from "@hyperframes/core/hf-ids";
|
||||
|
||||
export interface ParsedDocument {
|
||||
document: Document;
|
||||
/** True when the input was a fragment (no <html> shell) and was wrapped. */
|
||||
wrapped: boolean;
|
||||
/** ensureHfIds-stamped original HTML — used as fallback / diff base. */
|
||||
stamped: string;
|
||||
}
|
||||
|
||||
export function parseMutable(html: string): ParsedDocument {
|
||||
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);
|
||||
return { document: document as unknown as Document, wrapped, stamped };
|
||||
}
|
||||
|
||||
// ─── Element lookup ───────────────────────────────────────────────────────────
|
||||
|
||||
export function findById(document: Document, id: string): Element | null {
|
||||
// CSS.escape is browser-only; hf-ids are restricted identifiers so simple quote-escaping is safe.
|
||||
const escaped = id.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||
return document.querySelector(`[data-hf-id="${escaped}"]`);
|
||||
}
|
||||
|
||||
export function findRoot(document: Document): Element | null {
|
||||
return (
|
||||
document.querySelector("[data-hf-root]") ??
|
||||
document.getElementById("stage") ??
|
||||
document.body?.firstElementChild ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Inline style helpers ─────────────────────────────────────────────────────
|
||||
|
||||
function toCamel(prop: string): string {
|
||||
if (prop.startsWith("--")) return prop;
|
||||
return prop.replace(/-([a-z])/g, (_, c: string) => (c as string).toUpperCase());
|
||||
}
|
||||
|
||||
function toKebab(prop: string): string {
|
||||
if (prop.startsWith("--")) return prop;
|
||||
return prop.replace(/([A-Z])/g, (c) => `-${c.toLowerCase()}`);
|
||||
}
|
||||
|
||||
/** Parse style attribute string → camelCase map (custom props kept as-is). */
|
||||
function parseStyleAttr(styleAttr: string): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (const decl of styleAttr.split(";")) {
|
||||
const idx = decl.indexOf(":");
|
||||
if (idx === -1) continue;
|
||||
const rawProp = decl.slice(0, idx).trim();
|
||||
const value = decl.slice(idx + 1).trim();
|
||||
if (!rawProp || !value) continue;
|
||||
result[toCamel(rawProp)] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Serialize camelCase style map → style attribute string. */
|
||||
function serializeStyleAttr(styles: Record<string, string>): string {
|
||||
return Object.entries(styles)
|
||||
.map(([k, v]) => `${toKebab(k)}: ${v}`)
|
||||
.join("; ");
|
||||
}
|
||||
|
||||
export function getElementStyles(el: Element): Record<string, string> {
|
||||
const attr = el.getAttribute("style") ?? "";
|
||||
return parseStyleAttr(attr);
|
||||
}
|
||||
|
||||
export function setElementStyles(el: Element, updates: Record<string, string | null>): void {
|
||||
const current = getElementStyles(el);
|
||||
for (const [prop, value] of Object.entries(updates)) {
|
||||
if (value === null) {
|
||||
delete current[prop];
|
||||
} else {
|
||||
current[prop] = value;
|
||||
}
|
||||
}
|
||||
const serialized = serializeStyleAttr(current);
|
||||
if (serialized) {
|
||||
el.setAttribute("style", serialized);
|
||||
} else {
|
||||
el.removeAttribute("style");
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Text helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Read only direct (non-descendant) text node content. */
|
||||
export function getOwnText(el: Element): string {
|
||||
let text = "";
|
||||
el.childNodes.forEach((n) => {
|
||||
if (n.nodeType === 3) text += (n as Text).nodeValue ?? "";
|
||||
});
|
||||
return text;
|
||||
}
|
||||
|
||||
/** Replace only direct text nodes — preserves child elements. */
|
||||
export function setOwnText(el: Element, text: string): void {
|
||||
const doc = el.ownerDocument;
|
||||
const children = Array.from(el.childNodes);
|
||||
// Track original position of the first text node so we restore there, not at firstChild.
|
||||
let firstTextIdx = -1;
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
if (children[i]?.nodeType === 3) {
|
||||
firstTextIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (const child of children) {
|
||||
if (child.nodeType === 3) el.removeChild(child);
|
||||
}
|
||||
if (text) {
|
||||
// No text nodes before firstTextIdx (it's the first one), so index is stable.
|
||||
const current = Array.from(el.childNodes);
|
||||
const ref = firstTextIdx >= 0 ? (current[firstTextIdx] ?? null) : null;
|
||||
el.insertBefore(doc.createTextNode(text), ref);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Sibling index ────────────────────────────────────────────────────────────
|
||||
|
||||
export function getSiblingIndex(el: Element): number {
|
||||
const parent = el.parentElement;
|
||||
if (!parent) return 0;
|
||||
return Array.from(parent.children).indexOf(el);
|
||||
}
|
||||
Reference in New Issue
Block a user