/** * Stable hf- element id minting (R1). Node-safe (linkedom only, not browser DOM). * * Two surfaces share these helpers: * - ensureHfIds(html): node-id surface — mints data-hf-id on every element. * - mintHfId(el, assigned): shared by htmlParser for clip ids. * * Hash is CONTENT ONLY (tag + sorted attrs + own text) — no sibling position, * so inserting a non-identical sibling never shifts another element's id. */ import { parseHTML } from "linkedom"; // Non-editable / non-visual elements that should never receive a stable id. export const EXCLUDED_TAGS = new Set([ "script", "style", "template", "meta", "link", "noscript", "base", ]); // 32-bit FNV-1a. Pure, deterministic, no crypto, no Math.random. function fnv1a(str: string): number { let h = 0x811c9dc5; for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 0x01000193); } return h >>> 0; } // 4 base-36 chars · 36^4 ≈ 1.68M ids per document. Birthday-paradox collision // ≈ N²/(2·36^4): well under 1% per document after dup rehash at realistic // clip-model sizes (≤ a few hundred elements). The dup-rehash in mintHfId // resolves the rare collision; width is deliberately small for readable ids. function toHfId(hash: number): string { const s = (hash >>> 0).toString(36); // Use suffix (most-avalanched bits) for better distribution within the 4-char window. const four = s.length >= 4 ? s.slice(-4) : s.padStart(4, "0"); return `hf-${four}`; } // Element's own direct text (TEXT_NODE children), not descendants'. function ownText(el: Element): string { let text = ""; el.childNodes.forEach((n) => { if (n.nodeType === 3) text += (n as Text).nodeValue ?? ""; }); return text.trim(); } function contentKey(el: Element): string { // Exclude all data-hf-* attrs (ids, studio state) — they must not influence the hash. // Use \x00 / \x01 separators (invalid in HTML attrs) to prevent ambiguous serialization. const attrs = Array.from(el.attributes) .filter((a) => !a.name.startsWith("data-hf-")) .map((a) => `${a.name}\x00${a.value}`) .sort() .join("\x01"); return `${el.tagName.toLowerCase()}|${attrs}|${ownText(el)}`; } /** * Collision tiebreak for byte-identical siblings: document-order dup counter * (`hash(key#N)`). This IS order-dependent — two identical `` * get different ids based on which comes first in the DOM. This is unavoidable: * unique ids for byte-identical elements require a positional signal. * * Why this is safe in practice: once `ensureHfIds` write-back persists * `data-hf-id` to source the attribute is physically bound to its element. * Reordering identical siblings carries the attribute along → zero * order-dependence post-persist. `ensureHfIds` skips pinned elements * (`if (el.getAttribute("data-hf-id")) continue`), so normal operation * never re-exposes the ordering after first persist. */ // WIRE CONTRACT: id minting is content-keyed (FNV1a of innerHTML + tag). R7's // preview route relies on mintHfId producing identical ids across mint contexts // (disk-persist pass vs. in-memory bundle pass) — see preview.test.ts // "bundle returning untagged HTML gets same ids as disk". Any change that adds // positional, session, or random input to the hash breaks that invariant and // makes hf- ids diverge between disk and served HTML, silently corrupting // drag-to-edit targeting. export function mintHfId(el: Element, assigned: Set): string { const key = contentKey(el); let id = toHfId(fnv1a(key)); let dup = 0; while (assigned.has(id)) { dup += 1; // Graceful fallback instead of a hard throw: rehashing only fails to find a // free 4-char slot in a pathological document (~1.6M identical elements). // Rather than crash the whole parse, widen the id with the dup counter — // still deterministic and unique, just longer than the 4-char norm. if (dup > 10000) { id = `hf-${(fnv1a(key) >>> 0).toString(36)}-${dup}`; break; } id = toHfId(fnv1a(`${key}#${dup}`)); } assigned.add(id); return id; } /** * True for a sub-composition authoring template whose content the studio preview * unwraps into the served body. Two accepted forms: * A) `