/** * 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 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 = /]/i.test(stamped); const wrapped = !hasShell; const { document } = wrapped ? parseHTML(`
${stamped}`) : parseHTML(stamped); return { document: document as unknown as Document, wrapped, stamped }; } // ─── Element lookup ─────────────────────────────────────────────────────────── export function findById(document: Document, id: string): Element | null { // Delegate to resolveScoped so patch replay (undo/redo, override-set apply) // resolves an id the SAME way forward dispatch does: canonical-first for an // ambiguous bare id, and scoped-path ("hf-host/hf-leaf") aware. Otherwise the // two paths disagree on which duplicate a bare id targets and undo reverts the // wrong element. (function declaration is hoisted.) return resolveScoped(document, id); } export function escapeHfId(id: string): string { return id.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); } /** * querySelectorAll that also descends into COMPOSITION `` subtrees * (`data-composition-id` — the pattern the studio preview unwraps) — linkedom's * querySelectorAll does not, so template-based sub-comp content would be * unreachable for resolution/dispatch even though buildRoots models it. * * Implemented as a document-order DOM walk (not qsa + append) so duplicate-id * tiebreaks resolve in TRUE document order — appending template matches after * all top-level matches would make resolveScoped pick a different duplicate * than the preview's unwrapped DOM does. Plain templates (runtime clone * sources) are skipped, matching buildChildren and ensureHfIds. * * Throws like querySelectorAll on an invalid selector (Element.matches). */ export function querySelectorAllDeep(root: Document | Element, selector: string): Element[] { const out: Element[] = []; const start: Element | null = "body" in root ? ((root as Document).body ?? null) : (root as Element); const walk = (parent: Element): void => { for (const child of Array.from(parent.children)) { if (child.tagName.toLowerCase() === "template") { if (child.getAttribute("data-composition-id") !== null) walk(child); continue; } if (child.matches(selector)) out.push(child); walk(child); } }; if (start) { // When rooted at an Element (scoped-path step), the root itself is the // context, not a candidate — only descendants match, like querySelectorAll. walk(start); } return out; } /** * True when an element lives at the top-level (canonical) scope — i.e. its * scopedId equals its bare id because no ancestor opens a sub-composition * boundary. This mirrors document.ts's scopedId construction (childPrefix only * changes at isNewHostBoundary elements) without rebuilding the snapshot tree. */ function isCanonicalScope(el: Element): boolean { for (let cur = el.parentElement; cur; cur = cur.parentElement) { if (isNewHostBoundary(cur)) return false; } return true; } /** * Resolve a bare or scoped hf-id to its DOM element. * * Bare id ("hf-x"): top-level document search. When the bare id is ambiguous * (duplicated across a sub-composition and the top level), prefer the canonical * (top-level) instance — the one whose scopedId equals the bare id — falling * back to document order when no canonical match exists. This matches * getElement()'s resolution rule so removeElement / getElement agree on which * instance an ambiguous bare id targets. * * 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("/"); // Bare id: prefer the canonical (top-level) match when one exists, so // resolution agrees with getElement (scopedId === id wins over document order). if (parts.length === 1) { const escaped = escapeHfId(id); const matches = querySelectorAllDeep(document, `[data-hf-id="${escaped}"]`); if (matches.length > 0) { return matches.find((el) => isCanonicalScope(el)) ?? matches[0] ?? null; } // Fall back to a sub-composition ROOT addressed by its composition id. A // host element carries data-hf-id (its own leaf id) AND data-composition-id // (the id studio passes when targeting the sub-comp root). data-hf-id takes // precedence above; only when no hf-id matches do we treat the bare id as a // composition id, making comp-ids first-class resolvable addresses. return querySelectorAllDeep(document, `[data-composition-id="${escaped}"]`)[0] ?? null; } let context: Element | Document = document; for (const part of parts) { const escaped = escapeHfId(part); const found: Element | null = querySelectorAllDeep(context, `[data-hf-id="${escaped}"]`)[0] ?? null; if (!found) return null; context = found; } 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("/"); // split() always returns >=1 element, so this never actually falls through at // runtime — the fallback exists to satisfy noUncheckedIndexedAccess, not as a // reachable safety net. 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 * 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]") ?? document.getElementById("stage") ?? document.body?.firstElementChild ?? null ); } // ─── Inline style helpers ───────────────────────────────────────────────────── export 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). */ interface StyleDeclarationScan { depth: number; quote: "'" | '"' | null; skip: boolean; } function advanceStyleDeclarationScan(scan: StyleDeclarationScan, ch: string, next: string): void { if (scan.quote) { if (ch === "\\" && next) { scan.skip = true; return; } if (ch === scan.quote) scan.quote = null; return; } if (ch === "'" || ch === '"') { scan.quote = ch; return; } if (ch === "(") scan.depth++; else if (ch === ")") scan.depth = Math.max(0, scan.depth - 1); } function splitStyleDeclarations(style: string): string[] { const declarations: string[] = []; const scan: StyleDeclarationScan = { depth: 0, quote: null, skip: false }; let start = 0; for (let i = 0; i < style.length; i++) { if (scan.skip) { scan.skip = false; continue; } const ch = style[i] ?? ""; if (ch === ";" && scan.depth === 0 && scan.quote === null) { declarations.push(style.slice(start, i)); start = i + 1; } else { advanceStyleDeclarationScan(scan, ch, style[i + 1] ?? ""); } } declarations.push(style.slice(start)); return declarations; } function parseStyleAttr(styleAttr: string): Record