diff --git a/packages/sdk/src/document.ts b/packages/sdk/src/document.ts index 0bfdbaa8e..76774c0ae 100644 --- a/packages/sdk/src/document.ts +++ b/packages/sdk/src/document.ts @@ -10,7 +10,7 @@ import { parseHTML } from "linkedom"; import { ensureHfIds } from "@hyperframes/core/hf-ids"; -import { findRoot, getElementStyles } from "./engine/model.js"; +import { findRoot, getElementStyles, 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. @@ -38,13 +38,23 @@ function ownText(el: Element): string | null { } // fallow-ignore-next-line complexity -function buildElement(el: Element): HyperFramesElement | null { +function buildElement(el: Element, scopePrefix: 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") ?? ""; @@ -72,12 +82,13 @@ function buildElement(el: Element): HyperFramesElement | null { const children: HyperFramesElement[] = []; for (const child of Array.from(el.children)) { - const built = buildElement(child); + const built = buildElement(child, childPrefix); if (built) children.push(built); } return { id, + scopedId, tag, children, inlineStyles, @@ -142,7 +153,7 @@ export function buildRoots(document: Document): HyperFramesElement[] { const roots: HyperFramesElement[] = []; if (body) { for (const child of Array.from(body.children)) { - const built = buildElement(child); + const built = buildElement(child, ""); if (built) roots.push(built); } } diff --git a/packages/sdk/src/engine/model.ts b/packages/sdk/src/engine/model.ts index 74f80240c..683be1668 100644 --- a/packages/sdk/src/engine/model.ts +++ b/packages/sdk/src/engine/model.ts @@ -34,6 +34,49 @@ export function findById(document: Document, id: string): Element | null { return document.querySelector(`[data-hf-id="${escaped}"]`); } +function escapeHfId(id: string): string { + return id.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); +} + +/** + * Resolve a bare or scoped hf-id to its DOM element. + * + * Bare id ("hf-x"): equivalent to findById — top-level document search. + * Scoped id ("hf-HOST/hf-LEAF", any depth): each segment narrows the search + * into the subtree of the previous match. This unambiguously addresses an + * element inside a sub-composition even when bare ids collide. + */ +export function resolveScoped(document: Document, id: string): Element | null { + const parts = id.split("/"); + let context: Element | Document = document; + for (const part of parts) { + const escaped = escapeHfId(part); + const found: Element | null = + context === document + ? (context as Document).querySelector(`[data-hf-id="${escaped}"]`) + : (context as Element).querySelector(`[data-hf-id="${escaped}"]`); + if (!found) return null; + context = found; + } + return context as Element; +} + +/** + * 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 + * innerRoot of the SAME sub-composition (same dcf value as parent). + * + * outerHTML case: both host and innerRoot carry data-composition-file="sub.html". + * The innerRoot has the SAME value as the host (its parent) → not a new boundary. + * A genuine nested host inside a sub-comp has a DIFFERENT dcf value. + */ +export function isNewHostBoundary(el: Element): boolean { + const dcf = el.getAttribute("data-composition-file"); + if (!dcf) return false; + const parentDcf = el.parentElement?.getAttribute("data-composition-file") ?? null; + return dcf !== parentDcf; +} + export function findRoot(document: Document): Element | null { return ( document.querySelector("[data-hf-root]") ?? diff --git a/packages/sdk/src/engine/mutate.ts b/packages/sdk/src/engine/mutate.ts index 7bb4637be..912778c2c 100644 --- a/packages/sdk/src/engine/mutate.ts +++ b/packages/sdk/src/engine/mutate.ts @@ -11,7 +11,7 @@ import type { CanResult, EditOp, GsapTweenSpec, HfId, JsonPatchOp } from "../types.js"; import type { ParsedDocument } from "./model.js"; import { - findById, + resolveScoped, findRoot, getElementStyles, setElementStyles, @@ -195,7 +195,7 @@ function handleSetStyle( ): MutationResult { const result: MutationResult = { forward: [], inverse: [] }; for (const id of ids) { - const el = findById(parsed.document, id); + const el = resolveScoped(parsed.document, id); if (!el) continue; const old = getElementStyles(el); setElementStyles(el, styles); @@ -235,7 +235,7 @@ function handleMoveElement( function handleSetText(parsed: ParsedDocument, ids: HfId[], value: string): MutationResult { const result: MutationResult = { forward: [], inverse: [] }; for (const id of ids) { - const el = findById(parsed.document, id); + const el = resolveScoped(parsed.document, id); if (!el) continue; const oldText = getOwnText(el); setOwnText(el, value); @@ -260,7 +260,7 @@ function handleSetAttribute( validateSetAttribute(name, value); const result: MutationResult = { forward: [], inverse: [] }; for (const id of ids) { - const el = findById(parsed.document, id); + const el = resolveScoped(parsed.document, id); if (!el) continue; const oldValue = el.getAttribute(name); const path = attrPath(id, name); @@ -293,7 +293,7 @@ function handleSetTiming( let currentScript = origScript; for (const id of ids) { - const el = findById(parsed.document, id); + const el = resolveScoped(parsed.document, id); if (!el) continue; const oldStartStr = el.getAttribute("data-start"); @@ -373,7 +373,7 @@ function handleSetHold( ): MutationResult { const result: MutationResult = { forward: [], inverse: [] }; for (const id of ids) { - const el = findById(parsed.document, id); + const el = resolveScoped(parsed.document, id); if (!el) continue; const fields: Array<["start" | "end" | "fill", string]> = [ @@ -401,20 +401,28 @@ function handleRemoveElement(parsed: ParsedDocument, ids: HfId[]): MutationResul let currentScript = origScript; for (const id of ids) { - const el = findById(parsed.document, id); + const el = resolveScoped(parsed.document, id); if (!el) continue; const parentEl = el.parentElement; const parentId = parentEl?.getAttribute("data-hf-id") ?? null; const siblingIndex = getSiblingIndex(el); const html = el.outerHTML; + // Collect all bare hf-ids in the subtree BEFORE removal so GSAP cascade + // removes animations targeting any sub-composition element, not just the host. + const subtreeIds = collectSubtreeHfIds(el); + el.remove(); const path = elementPath(id); result.forward.push(patchRemove(path)); result.inverse.push(patchAdd(path, { html, parentId, siblingIndex })); - if (currentScript) currentScript = cascadeRemoveAnimations(currentScript, id); + if (currentScript) { + for (const subtreeId of subtreeIds) { + currentScript = cascadeRemoveAnimations(currentScript, subtreeId); + } + } } if (origScript && currentScript && currentScript !== origScript) { @@ -509,6 +517,18 @@ function selectorMatchesId(selector: string, id: HfId): boolean { ); } +/** Collect all bare data-hf-id values from el and all its descendants. */ +function collectSubtreeHfIds(el: Element): string[] { + const ids: string[] = []; + const own = el.getAttribute("data-hf-id"); + if (own) ids.push(own); + for (const child of Array.from(el.querySelectorAll("[data-hf-id]"))) { + const id = child.getAttribute("data-hf-id"); + if (id) ids.push(id); + } + return ids; +} + function cascadeRemoveAnimations(script: string, id: HfId): string { const parsedGsap = parseGsapScriptAcornForWrite(script); if (!parsedGsap) return script; @@ -749,7 +769,7 @@ export function validateOp(parsed: ParsedDocument, op: EditOp): CanResult { case "removeElement": { const ids = targets(op.target); if (ids.length === 0) return canErr("E_TARGET_NOT_FOUND", "No target ids provided."); - const missing = ids.filter((id) => findById(parsed.document, id) === null); + const missing = ids.filter((id) => resolveScoped(parsed.document, id) === null); if (missing.length > 0) return canErr( "E_TARGET_NOT_FOUND", diff --git a/packages/sdk/src/engine/patches.ts b/packages/sdk/src/engine/patches.ts index e21a0b06f..ae0fe68a2 100644 --- a/packages/sdk/src/engine/patches.ts +++ b/packages/sdk/src/engine/patches.ts @@ -30,30 +30,45 @@ import type { JsonPatchOp, PatchEvent } from "../types.js"; // ─── Path builders ──────────────────────────────────────────────────────────── +/** + * RFC 6902 JSON Pointer escaping for an hf-id (bare or scoped). + * Scoped ids contain "/" which must be encoded as "~1" in a path segment. + * "~" must be encoded as "~0" first (order matters per RFC 6902 §3). + */ +function escapeIdForPath(id: string): string { + return id.replace(/~/g, "~0").replace(/\//g, "~1"); +} + +/** Decode a path segment that may contain RFC 6902-escaped characters back to an hf-id. */ +function decodePathSegment(segment: string): string { + // RFC 6902 §3: unescape ~1 → /, then ~0 → ~ (reverse order) + return segment.replace(/~1/g, "/").replace(/~0/g, "~"); +} + export function stylePath(id: string, prop: string): string { - return `/elements/${id}/inlineStyles/${prop}`; + return `/elements/${escapeIdForPath(id)}/inlineStyles/${prop}`; } export function textPath(id: string): string { - return `/elements/${id}/text`; + return `/elements/${escapeIdForPath(id)}/text`; } export function attrPath(id: string, name: string): string { // RFC 6902 JSON Pointer: ~ → ~0, / → ~1 - const escaped = name.replace(/~/g, "~0").replace(/\//g, "~1"); - return `/elements/${id}/attributes/${escaped}`; + const escapedName = name.replace(/~/g, "~0").replace(/\//g, "~1"); + return `/elements/${escapeIdForPath(id)}/attributes/${escapedName}`; } export function timingPath(id: string, field: "start" | "end" | "trackIndex"): string { - return `/elements/${id}/timing/${field}`; + return `/elements/${escapeIdForPath(id)}/timing/${field}`; } export function holdPath(id: string, field: "start" | "end" | "fill"): string { - return `/elements/${id}/hold/${field}`; + return `/elements/${escapeIdForPath(id)}/hold/${field}`; } export function elementPath(id: string): string { - return `/elements/${id}`; + return `/elements/${escapeIdForPath(id)}`; } export function variablePath(id: string): string { @@ -80,29 +95,30 @@ export function styleSheetPath(): string { */ export function pathToKey(path: string): string | null { // /elements/{id}/inlineStyles/{prop} → "{id}.style.{prop}" + // id segment may contain ~1 (RFC 6902-escaped "/") for scoped ids const styleMatch = /^\/elements\/([^/]+)\/inlineStyles\/(.+)$/.exec(path); - if (styleMatch) return `${styleMatch[1]}.style.${styleMatch[2]}`; + if (styleMatch) return `${decodePathSegment(styleMatch[1]!)}.style.${styleMatch[2]}`; // /elements/{id}/text → "{id}.text" const textMatch = /^\/elements\/([^/]+)\/text$/.exec(path); - if (textMatch) return `${textMatch[1]}.text`; + if (textMatch) return `${decodePathSegment(textMatch[1]!)}.text`; // /elements/{id}/attributes/{name} → "{id}.attr.{name}" const attrMatch = /^\/elements\/([^/]+)\/attributes\/(.+)$/.exec(path); - if (attrMatch) return `${attrMatch[1]}.attr.${attrMatch[2]}`; + if (attrMatch) return `${decodePathSegment(attrMatch[1]!)}.attr.${attrMatch[2]}`; // /elements/{id}/timing/{field} → "{id}.timing.{field}" // Note: field "end" maps to the computed data-end attribute value. const timingMatch = /^\/elements\/([^/]+)\/timing\/(.+)$/.exec(path); - if (timingMatch) return `${timingMatch[1]}.timing.${timingMatch[2]}`; + if (timingMatch) return `${decodePathSegment(timingMatch[1]!)}.timing.${timingMatch[2]}`; // /elements/{id}/hold/{field} → "{id}.hold.{field}" const holdMatch = /^\/elements\/([^/]+)\/hold\/(.+)$/.exec(path); - if (holdMatch) return `${holdMatch[1]}.hold.${holdMatch[2]}`; + if (holdMatch) return `${decodePathSegment(holdMatch[1]!)}.hold.${holdMatch[2]}`; // /elements/{id} (whole element) → "{id}" const elemMatch = /^\/elements\/([^/]+)$/.exec(path); - if (elemMatch) return elemMatch[1] ?? null; + if (elemMatch) return decodePathSegment(elemMatch[1]!) ?? null; // /variables/{id} → "var.{id}" const varMatch = /^\/variables\/(.+)$/.exec(path); @@ -133,9 +149,10 @@ export function keyToPath(key: string): string | null { if (text?.[1]) return textPath(text[1]); const attr = /^([^.]+)\.attr\.(.+)$/.exec(key); - // pathToKey stores the RFC 6902-encoded segment verbatim; do NOT call attrPath() - // here (it would re-escape '~' → '~0'), just reconstruct the path directly. - if (attr?.[1] && attr[2]) return `/elements/${attr[1]}/attributes/${attr[2]}`; + // The attr name segment in the key is already RFC 6902-encoded (pathToKey stored it verbatim). + // The id may be a scoped id (contains "/") so we must escape it, but must NOT re-escape + // the already-encoded attr segment. Reconstruct manually. + if (attr?.[1] && attr[2]) return `/elements/${escapeIdForPath(attr[1])}/attributes/${attr[2]}`; const timing = /^([^.]+)\.timing\.(start|end|trackIndex)$/.exec(key); if (timing?.[1]) return timingPath(timing[1], timing[2] as "start" | "end" | "trackIndex"); diff --git a/packages/sdk/src/session.subcomp.test.ts b/packages/sdk/src/session.subcomp.test.ts new file mode 100644 index 000000000..f66fcb6a9 --- /dev/null +++ b/packages/sdk/src/session.subcomp.test.ts @@ -0,0 +1,353 @@ +/** + * T-contract: sub-composition scoped id suite (Stage 6 / F9). + * + * All tests use pre-inlined HTML (flat DOM with data-composition-file boundaries) + * because the SDK only opens pre-inlined HTML — sub-comp loading is not the SDK's job. + * + * Boundary detection rule: an element is a host (starts a new scope) when it has + * data-composition-file AND its value differs from its parent's data-composition-file. + * This correctly handles the outerHTML innerRoot case (same dcf as parent → not a new host) + * and nested hosts (different dcf from parent → new host). + */ + +import { describe, it, expect } from "vitest"; +import { parseHTML } from "linkedom"; +import { ensureHfIds } from "@hyperframes/core/hf-ids"; +import { resolveScoped } from "./engine/model.js"; +import { parseMutable } from "./engine/model.js"; +import { buildRoots, flatElements } from "./document.js"; +import { openComposition } from "./session.js"; + +// ─── Fixture helpers ────────────────────────────────────────────────────────── + +/** Build a flat inlined HTML string simulating what inlineSubCompositions produces. */ +function inlinedHtml(inner: string): string { + return `
${inner}`; +} + +/** Stamp hf-ids and return a linkedom document (same as parseMutable's path). */ +function makeDoc(html: string) { + const { document } = parseHTML(ensureHfIds(html)); + return document; +} + +// ─── 1. resolveScoped ───────────────────────────────────────────────────────── + +describe("resolveScoped — flat id", () => { + it("resolves a bare id at top level (same as findById)", () => { + const doc = makeDoc( + `text
+inside
+outside
+ `), + ); + const el = resolveScoped(doc as unknown as Document, "hf-host/hf-dup"); + expect(el?.getAttribute("class")).toBe("inside"); + }); + + it("resolves 3-level nesting hf-H1/hf-H2/hf-leaf", () => { + const doc = makeDoc( + inlinedHtml(` +text
+hi
text
+text
+A
+B
+text
+inside
+outside
+text
+inside text
+inside
+outside
+text
+text
+text
+outer
+