@@ -41,7 +41,7 @@ describe("T2 — stable element ids (spec for R1)", () => {
}
});
- it.fails("[spec] adding an element before existing ones does not change existing ids", () => {
+ it("[spec] adding an element before existing ones does not change existing ids", () => {
const base = `
@@ -62,12 +62,12 @@ describe("T2 — stable element ids (spec for R1)", () => {
// --- Baseline (already pass, must not regress) ---
- it("elements with an existing id keep it unchanged", () => {
+ it("existing data-hf-id is pinned and becomes the clip id (never re-minted)", () => {
const html = `
`;
const { elements } = parseHtml(html);
- expect(elements.some((e) => e.id === "my-title")).toBe(true);
+ expect(elements.some((e) => e.id === "hf-anch")).toBe(true);
});
it("ids are deterministic: same input produces same ids on re-parse", () => {
diff --git a/packages/core/src/studio-api/helpers/hfIdPersist.test.ts b/packages/core/src/studio-api/helpers/hfIdPersist.test.ts
new file mode 100644
index 000000000..e0e0ab154
--- /dev/null
+++ b/packages/core/src/studio-api/helpers/hfIdPersist.test.ts
@@ -0,0 +1,69 @@
+import { describe, it, expect, afterEach } from "vitest";
+import { mkdtempSync, writeFileSync, readFileSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { normalizeHfIds, persistHfIdsIfNeeded } from "./hfIdPersist.js";
+
+describe("normalizeHfIds", () => {
+ it("marks changed=true and adds data-hf-id to all body elements when untagged", () => {
+ const raw = `
`;
+ const { html, changed } = normalizeHfIds(raw);
+ expect(changed).toBe(true);
+ expect(html).toContain('data-hf-id="hf-');
+ const matches = html.match(/data-hf-id="hf-[a-z0-9]{4}"/g);
+ expect(matches?.length).toBeGreaterThanOrEqual(2);
+ });
+
+ it("marks changed=false for already-normalized HTML (idempotent round-trip)", () => {
+ const raw = `
`;
+ const first = normalizeHfIds(raw).html;
+ const { html, changed } = normalizeHfIds(first);
+ expect(changed).toBe(false);
+ expect(html).toBe(first);
+ });
+});
+
+describe("persistHfIdsIfNeeded", () => {
+ const tmpDirs: string[] = [];
+
+ afterEach(() => {
+ for (const d of tmpDirs) rmSync(d, { recursive: true, force: true });
+ tmpDirs.length = 0;
+ });
+
+ function tmpFile(content: string): string {
+ const dir = mkdtempSync(join(tmpdir(), "hfid-test-"));
+ tmpDirs.push(dir);
+ const file = join(dir, "index.html");
+ writeFileSync(file, content, "utf-8");
+ return file;
+ }
+
+ it("writes data-hf-id to disk when source is untagged", () => {
+ const raw = `
hello
`;
+ const file = tmpFile(raw);
+ const returned = persistHfIdsIfNeeded(file, raw);
+ expect(returned).toContain('data-hf-id="hf-');
+ const onDisk = readFileSync(file, "utf-8");
+ expect(onDisk).toContain('data-hf-id="hf-');
+ expect(onDisk).toBe(returned);
+ });
+
+ it("does not rewrite disk when source is already tagged", () => {
+ const raw = `
hello
`;
+ const file = tmpFile(raw);
+ const tagged = persistHfIdsIfNeeded(file, raw);
+ const diskAfterFirst = readFileSync(file, "utf-8");
+ const returned2 = persistHfIdsIfNeeded(file, tagged);
+ expect(returned2).toBe(tagged);
+ expect(readFileSync(file, "utf-8")).toBe(diskAfterFirst);
+ });
+
+ it("returned id matches id written to disk (serve-time == persist-time invariant)", () => {
+ const raw = `
text`;
+ const file = tmpFile(raw);
+ const result = persistHfIdsIfNeeded(file, raw);
+ const onDisk = readFileSync(file, "utf-8");
+ expect(result).toBe(onDisk);
+ });
+});
diff --git a/packages/core/src/studio-api/helpers/hfIdPersist.ts b/packages/core/src/studio-api/helpers/hfIdPersist.ts
new file mode 100644
index 000000000..8ec6bfdc4
--- /dev/null
+++ b/packages/core/src/studio-api/helpers/hfIdPersist.ts
@@ -0,0 +1,21 @@
+import { ensureHfIds } from "../../parsers/hfIds.js";
+import { writeFileSync } from "node:fs";
+
+export { ensureHfIds };
+
+export function normalizeHfIds(html: string): { html: string; changed: boolean } {
+ const normalized = ensureHfIds(html);
+ return { html: normalized, changed: normalized !== html };
+}
+
+export function persistHfIdsIfNeeded(filePath: string, html: string): string {
+ const { html: normalized, changed } = normalizeHfIds(html);
+ if (changed) {
+ try {
+ writeFileSync(filePath, normalized, "utf-8");
+ } catch {
+ // non-fatal — serve with ids even if persist fails
+ }
+ }
+ return normalized;
+}
diff --git a/packages/core/src/studio-api/helpers/previewAdapter.test.ts b/packages/core/src/studio-api/helpers/previewAdapter.test.ts
index 860dd39f0..eb79f92a6 100644
--- a/packages/core/src/studio-api/helpers/previewAdapter.test.ts
+++ b/packages/core/src/studio-api/helpers/previewAdapter.test.ts
@@ -1,75 +1,222 @@
+// fallow-ignore-file code-duplication
/**
* T10 — PreviewAdapter contract (spec for R7).
*
- * `createPreviewAdapter` does not exist yet. These stubs define the expected
- * interface so R7 has a concrete target. Convert from it.todo to real
- * assertions in the R7 PR.
+ * Converted from it.todo stubs. These tests FAIL until Task 3 implements
+ * createPreviewAdapter in ./previewAdapter.ts.
*
- * Hit-testing (elementAtPoint) in both linkedom and jsdom returns null for
- * all geometry calls — the real tests must inject a position-resolver stub
- * or mock elementFromPoint. The contract tested is filtering logic (root
- * exclusion, data-hf-id ancestor walk, opacity-at-playhead), not geometry.
+ * Position resolution: elementFromPoint is always null in jsdom. All
+ * elementAtPoint tests inject a resolvePoint stub so the contract tested
+ * is filtering logic (root exclusion, data-hf-id ancestor walk,
+ * opacity-at-playhead), not geometry.
+ *
+ * CSS custom property names used below mirror the Studio constants from
+ * manualEditsTypes.ts — they will be shared with the PreviewAdapter
+ * implementation once the draft-marker module moves to core (Task 4).
*/
-import { describe, it } from "vitest";
+import { describe, it, expect, beforeEach } from "vitest";
+import { createPreviewAdapter } from "./previewAdapter.js";
-describe("T10 — PreviewAdapter contract (spec for R7)", () => {
- describe("elementAtPoint", () => {
- it.todo("returns null for the stage root (data-hf-root)");
+// ── DOM helpers ────────────────────────────────────────────────────────────
- it.todo("returns the nearest ancestor with data-hf-id");
+beforeEach(() => {
+ document.body.innerHTML = "";
+});
- it.todo("returns null when the hit element has no data-hf-id ancestor");
+/** Create + append an element to body; optionally set attrs and inline styles. */
+function make(
+ tag: string,
+ attrs: Record
= {},
+ styles: Record = {},
+): HTMLElement {
+ const elem = document.createElement(tag);
+ for (const [k, v] of Object.entries(attrs)) elem.setAttribute(k, v);
+ for (const [k, v] of Object.entries(styles)) elem.style.setProperty(k, v);
+ document.body.appendChild(elem);
+ return elem;
+}
+
+function adapterWith(resolvePoint: (x: number, y: number) => Element | null) {
+ return createPreviewAdapter(document, { resolvePoint });
+}
+
+// ── elementAtPoint ─────────────────────────────────────────────────────────
- it.todo("skips elements whose computed opacity is 0 at the given playhead time");
+describe("T10 — PreviewAdapter contract (spec for R7)", () => {
+ describe("elementAtPoint", () => {
+ it("returns null for the stage root (data-hf-root)", () => {
+ const root = make("div", { "data-hf-root": "true" });
+ const adapter = adapterWith(() => root);
+ expect(adapter.elementAtPoint(0, 0)).toBeNull();
+ });
+
+ it("returns the nearest ancestor with data-hf-id", () => {
+ const parent = make("div", { "data-hf-id": "hf-abcd" });
+ const child = document.createElement("span");
+ parent.appendChild(child);
+ const adapter = adapterWith(() => child);
+ expect(adapter.elementAtPoint(0, 0)).toBe(parent);
+ });
+
+ it("returns null when the hit element has no data-hf-id ancestor", () => {
+ const orphan = make("div");
+ const adapter = adapterWith(() => orphan);
+ expect(adapter.elementAtPoint(0, 0)).toBeNull();
+ });
+
+ it("skips elements whose computed opacity is 0 at the given playhead time", () => {
+ const elem = make("div", { "data-hf-id": "hf-zzzz" }, { opacity: "0" });
+ const adapter = adapterWith(() => elem);
+ expect(adapter.elementAtPoint(0, 0, { atTime: 1.0 })).toBeNull();
+ });
});
- describe("applyDraft / revertDraft", () => {
- it.todo("applyDraft writes --hf-studio-* CSS props and sets the gesture marker");
-
- it.todo("applyDraft accepts a move payload (dx/dy) and writes the translate draft");
-
- it.todo("applyDraft accepts a resize payload (w/h) and writes the size draft");
+ // ── applyDraft / revertDraft ───────────────────────────────────────────
- it.todo("revertDraft removes draft props and clears the gesture marker");
-
- it.todo("revertDraft restores original translate when an original was recorded");
+ describe("applyDraft / revertDraft", () => {
+ it("applyDraft writes --hf-studio-* CSS props and sets the gesture marker", () => {
+ const target = make("div", { "data-hf-id": "hf-aaaa" });
+ const adapter = adapterWith(() => null);
+ adapter.applyDraft({ type: "move", hfId: "hf-aaaa", dx: 10, dy: 20 });
+ expect(target.style.getPropertyValue("--hf-studio-offset-x")).not.toBe("");
+ expect(target.hasAttribute("data-hf-studio-manual-edit-gesture")).toBe(true);
+ });
+
+ it("applyDraft accepts a move payload (dx/dy) and writes the translate draft", () => {
+ const target = make("div", { "data-hf-id": "hf-aaaa" });
+ const adapter = adapterWith(() => null);
+ adapter.applyDraft({ type: "move", hfId: "hf-aaaa", dx: 30, dy: 15 });
+ expect(target.style.getPropertyValue("--hf-studio-offset-x")).toBe("30px");
+ expect(target.style.getPropertyValue("--hf-studio-offset-y")).toBe("15px");
+ });
+
+ it("applyDraft accepts a resize payload (w/h) and writes the size draft", () => {
+ const target = make("div", { "data-hf-id": "hf-aaaa" });
+ const adapter = adapterWith(() => null);
+ adapter.applyDraft({ type: "resize", hfId: "hf-aaaa", w: 200, h: 100 });
+ expect(target.style.getPropertyValue("--hf-studio-width")).toBe("200px");
+ expect(target.style.getPropertyValue("--hf-studio-height")).toBe("100px");
+ });
+
+ it("revertDraft removes draft props and clears the gesture marker", () => {
+ const target = make("div", { "data-hf-id": "hf-aaaa" });
+ const adapter = adapterWith(() => null);
+ adapter.applyDraft({ type: "move", hfId: "hf-aaaa", dx: 10, dy: 20 });
+ adapter.revertDraft();
+ expect(target.style.getPropertyValue("--hf-studio-offset-x")).toBe("");
+ expect(target.style.getPropertyValue("--hf-studio-offset-y")).toBe("");
+ expect(target.hasAttribute("data-hf-studio-manual-edit-gesture")).toBe(false);
+ });
+
+ it("revertDraft restores original translate when an original was recorded", () => {
+ const target = make("div", { "data-hf-id": "hf-aaaa" });
+ target.style.setProperty("translate", "50px 0px");
+ const adapter = adapterWith(() => null);
+ adapter.applyDraft({ type: "move", hfId: "hf-aaaa", dx: 10, dy: 0 });
+ adapter.revertDraft();
+ expect(target.style.getPropertyValue("translate")).toBe("50px 0px");
+ });
});
- describe("applyDraft edge cases (R7 implementation contract)", () => {
- it.todo(
- "second applyDraft before revert/commit overwrites first draft — does not accumulate (dx/dy)",
- );
-
- it.todo(
- "revertDraft is safe to call when no gesture is in progress (idempotent / no-op on empty marker)",
- );
-
- it.todo(
- "elementAtPoint filtering is stable when playhead changes mid-drag — opacity re-evaluated per call",
- );
+ // ── edge cases ─────────────────────────────────────────────────────────
- it.todo(
- "stage-root exclusion applies only to the outermost data-hf-root; nested sub-composition roots count as targets",
- );
+ describe("applyDraft edge cases (R7 implementation contract)", () => {
+ it("second applyDraft before revert/commit overwrites first draft — does not accumulate (dx/dy)", () => {
+ const target = make("div", { "data-hf-id": "hf-aaaa" });
+ const adapter = adapterWith(() => null);
+ adapter.applyDraft({ type: "move", hfId: "hf-aaaa", dx: 10, dy: 20 });
+ adapter.applyDraft({ type: "move", hfId: "hf-aaaa", dx: 5, dy: 15 });
+ expect(target.style.getPropertyValue("--hf-studio-offset-x")).toBe("5px");
+ expect(target.style.getPropertyValue("--hf-studio-offset-y")).toBe("15px");
+ });
+
+ it("revertDraft is safe to call when no gesture is in progress (idempotent / no-op on empty marker)", () => {
+ const adapter = adapterWith(() => null);
+ expect(() => adapter.revertDraft()).not.toThrow();
+ expect(() => adapter.revertDraft()).not.toThrow();
+ });
+
+ it("elementAtPoint filtering is stable when playhead changes mid-drag — opacity re-evaluated per call", () => {
+ const elem = make("div", { "data-hf-id": "hf-zzzz" });
+ const adapter = adapterWith(() => elem);
+ expect(adapter.elementAtPoint(0, 0, { atTime: 0 })).toBe(elem);
+ // simulates GSAP seeking to a time where the element is hidden
+ elem.style.setProperty("opacity", "0");
+ expect(adapter.elementAtPoint(0, 0, { atTime: 1.0 })).toBeNull();
+ });
+
+ it("stage-root exclusion applies only to the outermost data-hf-root; nested sub-composition roots count as targets", () => {
+ const outerRoot = make("div", { "data-hf-root": "true" });
+ const innerRoot = document.createElement("div");
+ innerRoot.setAttribute("data-hf-root", "true");
+ innerRoot.setAttribute("data-hf-id", "hf-sub1");
+ outerRoot.appendChild(innerRoot);
+
+ const adapterOuter = adapterWith(() => outerRoot);
+ expect(adapterOuter.elementAtPoint(0, 0)).toBeNull();
+
+ const adapterInner = adapterWith(() => innerRoot);
+ expect(adapterInner.elementAtPoint(0, 0)).toBe(innerRoot);
+ });
});
- describe("commitPreview", () => {
- it.todo("returns null when no gesture marker is present");
-
- it.todo("derives a moveElement patch from draft markers on commit");
+ // ── commitPreview ──────────────────────────────────────────────────────
- it.todo("derives a resize patch from draft markers on commit");
-
- it.todo("clears the gesture marker after commit");
+ describe("commitPreview", () => {
+ it("returns null when no gesture marker is present", () => {
+ const adapter = adapterWith(() => null);
+ expect(adapter.commitPreview()).toBeNull();
+ });
+
+ it("derives a moveElement patch from draft markers on commit", () => {
+ make("div", { "data-hf-id": "hf-aaaa" });
+ const adapter = adapterWith(() => null);
+ adapter.applyDraft({ type: "move", hfId: "hf-aaaa", dx: 30, dy: 15 });
+ const patch = adapter.commitPreview();
+ expect(patch).toEqual({ type: "moveElement", hfId: "hf-aaaa", dx: 30, dy: 15 });
+ });
+
+ it("derives a resize patch from draft markers on commit", () => {
+ make("div", { "data-hf-id": "hf-aaaa" });
+ const adapter = adapterWith(() => null);
+ adapter.applyDraft({ type: "resize", hfId: "hf-aaaa", w: 200, h: 100 });
+ const patch = adapter.commitPreview();
+ expect(patch).toEqual({ type: "resize", hfId: "hf-aaaa", width: 200, height: 100 });
+ });
+
+ it("clears the gesture marker after commit", () => {
+ const target = make("div", { "data-hf-id": "hf-aaaa" });
+ const adapter = adapterWith(() => null);
+ adapter.applyDraft({ type: "move", hfId: "hf-aaaa", dx: 10, dy: 0 });
+ adapter.commitPreview();
+ expect(target.hasAttribute("data-hf-studio-manual-edit-gesture")).toBe(false);
+ });
});
- describe("getElementTimings", () => {
- it.todo("reads authored absolute times from data-start / data-end");
+ // ── getElementTimings ──────────────────────────────────────────────────
- it.todo("ignores elements without data-hf-id");
-
- it.todo(
- "returns a defined timing entry when data-hf-id is present but data-start / data-end are missing",
- );
+ describe("getElementTimings", () => {
+ it("reads authored absolute times from data-start / data-end", () => {
+ make("div", { "data-hf-id": "hf-t1", "data-start": "0.5", "data-end": "2.0" });
+ const adapter = adapterWith(() => null);
+ const timings = adapter.getElementTimings();
+ expect(timings["hf-t1"]).toEqual({ start: 0.5, end: 2.0 });
+ });
+
+ it("ignores elements without data-hf-id", () => {
+ make("div", { "data-start": "0.5", "data-end": "2.0" }); // no data-hf-id
+ const adapter = adapterWith(() => null);
+ const timings = adapter.getElementTimings();
+ expect(Object.keys(timings)).toHaveLength(0);
+ });
+
+ it("returns a defined timing entry when data-hf-id is present but data-start / data-end are missing", () => {
+ make("div", { "data-hf-id": "hf-notimed" });
+ const adapter = adapterWith(() => null);
+ const timings = adapter.getElementTimings();
+ expect(timings["hf-notimed"]).toBeDefined();
+ expect(timings["hf-notimed"].start).toBeUndefined();
+ expect(timings["hf-notimed"].end).toBeUndefined();
+ });
});
});
diff --git a/packages/core/src/studio-api/helpers/previewAdapter.ts b/packages/core/src/studio-api/helpers/previewAdapter.ts
new file mode 100644
index 000000000..6a6b4f022
--- /dev/null
+++ b/packages/core/src/studio-api/helpers/previewAdapter.ts
@@ -0,0 +1,28 @@
+/**
+ * PreviewAdapter — stub for R7 (Task 3 implements this).
+ * Exports the typed API contract so tests can import and fail on assertions
+ * rather than module resolution.
+ */
+
+export type DraftPayload =
+ | { type: "move"; hfId: string; dx: number; dy: number }
+ | { type: "resize"; hfId: string; w: number; h: number };
+
+export type CommitPatch =
+ | { type: "moveElement"; hfId: string; dx: number; dy: number }
+ | { type: "resize"; hfId: string; width: number; height: number };
+
+export interface PreviewAdapter {
+ elementAtPoint(x: number, y: number, opts?: { atTime?: number }): Element | null;
+ applyDraft(payload: DraftPayload): void;
+ revertDraft(): void;
+ commitPreview(): CommitPatch | null;
+ getElementTimings(): Record;
+}
+
+export function createPreviewAdapter(
+ _document: Document,
+ _opts?: { resolvePoint?: (x: number, y: number) => Element | null },
+): PreviewAdapter {
+ throw new Error("not implemented — Task 3");
+}
diff --git a/packages/core/src/studio-api/helpers/sourceMutation.test.ts b/packages/core/src/studio-api/helpers/sourceMutation.test.ts
index c23b45911..8915d411d 100644
--- a/packages/core/src/studio-api/helpers/sourceMutation.test.ts
+++ b/packages/core/src/studio-api/helpers/sourceMutation.test.ts
@@ -368,13 +368,90 @@ describe("probeElementInSource", () => {
// Covers the same surface as T3 (Studio sourcePatcher) — Core sourceMutation supports
// all patch types (inline-style, attribute, text-content) via patchElementInHtml.
describe("T7 — data-hf-id targeting (spec for R1)", () => {
- it.todo("updates inline style by data-hf-id when no HTML id attribute is present");
+ it("updates inline style by data-hf-id when no HTML id attribute is present", () => {
+ const source = `Hello
`;
+ const { html, matched } = patchElementInHtml(source, { hfId: "hf-x7k2" }, [
+ { type: "inline-style", property: "color", value: "blue" },
+ ]);
+ expect(matched).toBe(true);
+ expect(html).toMatch(/color:\s*blue/);
+ expect(html).toContain('data-hf-id="hf-x7k2"');
+ });
- it.todo("updates text content by data-hf-id");
+ it("updates text content by data-hf-id", () => {
+ const source = `Old text
`;
+ const { html, matched } = patchElementInHtml(source, { hfId: "hf-a1b2" }, [
+ { type: "text-content", property: "", value: "New text" },
+ ]);
+ expect(matched).toBe(true);
+ expect(html).toContain("New text");
+ });
- it.todo("updates attribute by data-hf-id");
+ it("updates attribute by data-hf-id", () => {
+ const source = ``;
+ const { html, matched } = patchElementInHtml(source, { hfId: "hf-c3d4" }, [
+ { type: "attribute", property: "start", value: "2.5" },
+ ]);
+ expect(matched).toBe(true);
+ expect(html).toContain('data-start="2.5"');
+ });
- it.todo("data-hf-id attribute survives the patch (can be targeted again)");
+ it("data-hf-id attribute survives the patch (can be targeted again)", () => {
+ const source = `Hello
`;
+ const { html } = patchElementInHtml(source, { hfId: "hf-x7k2" }, [
+ { type: "inline-style", property: "color", value: "blue" },
+ ]);
+ expect(html).toContain('data-hf-id="hf-x7k2"');
+ });
- it.todo("hfId lookup falls through to selector when hfId is not found in the document");
+ it("hfId lookup falls through to selector when hfId is not found in the document", () => {
+ const source = `Hello
`;
+ const { html, matched } = patchElementInHtml(
+ source,
+ { hfId: "hf-missing", selector: ".headline" },
+ [{ type: "inline-style", property: "color", value: "blue" }],
+ );
+ expect(matched).toBe(true);
+ expect(html).toMatch(/color:\s*blue/);
+ });
+
+ it("does not break out of the selector on a crafted hfId (CSS injection guard)", () => {
+ // A value with a quote/bracket must be escaped, not injected — it should
+ // simply match nothing and leave the source untouched, never throw.
+ const source = `A
B
`;
+ const evil = `x"] , [class="victim`;
+ const run = () =>
+ patchElementInHtml(source, { hfId: evil }, [
+ { type: "text-content", property: "textContent", value: "HACKED" },
+ ]);
+ expect(run).not.toThrow();
+ const { html, matched } = run();
+ expect(matched).toBe(false);
+ expect(html).toBe(source);
+ expect(html).not.toContain("HACKED");
+ });
+
+ // The Studio edit path targets by id/selector (it never sends hfId). Once a
+ // persisted data-hf-id exists in source, those edits must NOT strip it — else
+ // the stable handle is destroyed by the next edit. This is the preservation
+ // guarantee the write-back design depends on.
+ it("preserves an existing data-hf-id when the element is patched by id", () => {
+ const source = `Hello
`;
+ const { html, matched } = patchElementInHtml(source, { id: "hero" }, [
+ { type: "inline-style", property: "color", value: "blue" },
+ ]);
+ expect(matched).toBe(true);
+ expect(html).toMatch(/color:\s*blue/);
+ expect(html).toContain('data-hf-id="hf-x7k2"');
+ });
+
+ it("preserves an existing data-hf-id when the element is patched by selector", () => {
+ const source = `Old
`;
+ const { html, matched } = patchElementInHtml(source, { selector: ".body" }, [
+ { type: "text-content", property: "textContent", value: "New" },
+ ]);
+ expect(matched).toBe(true);
+ expect(html).toContain("New");
+ expect(html).toContain('data-hf-id="hf-a1b2"');
+ });
});
diff --git a/packages/core/src/studio-api/helpers/sourceMutation.ts b/packages/core/src/studio-api/helpers/sourceMutation.ts
index 8cdc5ae05..d7c2c2ef7 100644
--- a/packages/core/src/studio-api/helpers/sourceMutation.ts
+++ b/packages/core/src/studio-api/helpers/sourceMutation.ts
@@ -2,6 +2,7 @@ import { parseHTML } from "linkedom";
export interface SourceMutationTarget {
id?: string | null;
+ hfId?: string;
selector?: string;
selectorIndex?: number;
}
@@ -31,7 +32,41 @@ function querySelectorAllWithTemplates(root: Document | Element, selector: strin
return [];
}
+// Prevent CSS attribute-selector injection via a crafted hfId: escape
+// backslashes first, then double-quotes. Keeps a malformed/hostile value from
+// breaking out of the `[data-hf-id="…"]` selector once callers beyond the
+// internal mint contract (R2+ user flows) pass values here.
+function escapeCssAttrValue(value: string): string {
+ return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
+}
+
+function findByHfId(document: Document, hfId: string): Element | null {
+ try {
+ const matches = querySelectorAllWithTemplates(
+ document,
+ `[data-hf-id="${escapeCssAttrValue(hfId)}"]`,
+ );
+ if (matches.length > 1) {
+ // The mint contract guarantees uniqueness; a duplicate means upstream
+ // id drift. Don't silently patch an arbitrary one — surface it.
+ // eslint-disable-next-line no-console
+ console.warn(
+ `sourceMutation: data-hf-id "${hfId}" matched ${matches.length} elements; using the first. ids must be unique per document.`,
+ );
+ }
+ return matches[0] ?? null;
+ } catch {
+ // Malformed selector despite escaping — let the caller fall back.
+ return null;
+ }
+}
+
function findTargetElement(document: Document, target: SourceMutationTarget): Element | null {
+ if (target.hfId) {
+ const el = findByHfId(document, target.hfId);
+ if (el) return el;
+ }
+
if (target.id) {
const byId = document.getElementById(target.id);
if (byId) return byId;
@@ -207,7 +242,7 @@ export function patchElementInHtml(
}
export function probeElementInSource(source: string, target: SourceMutationTarget): boolean {
- if (!target.id && !target.selector) return false;
+ if (!target.id && !target.hfId && !target.selector) return false;
const { document } = parseSourceDocument(source);
const el = findTargetElement(document, target);
return el != null && isHTMLElement(el);
diff --git a/packages/core/src/studio-api/routes/preview.test.ts b/packages/core/src/studio-api/routes/preview.test.ts
index 54cd0a6c4..c2c6ca5b2 100644
--- a/packages/core/src/studio-api/routes/preview.test.ts
+++ b/packages/core/src/studio-api/routes/preview.test.ts
@@ -328,3 +328,36 @@ describe("registerPreviewRoutes", () => {
expect(signature).toMatch(/^[a-f0-9]{24}$/);
});
});
+
+describe("hf-id surfacing in preview route", () => {
+ it("serves HTML with data-hf-id on body elements (R7 write-back)", async () => {
+ const projectDir = createProjectDir();
+ writeFileSync(
+ join(projectDir, "index.html"),
+ ``,
+ );
+ const app = new Hono();
+ registerPreviewRoutes(app, createAdapter(projectDir));
+ const res = await app.request("http://localhost/projects/demo/preview");
+ expect(res.status).toBe(200);
+ const html = await res.text();
+ const ids = html.match(/data-hf-id="hf-[a-z0-9]{4}"/g);
+ // div and p both tagged
+ expect(ids?.length).toBeGreaterThanOrEqual(2);
+ });
+
+ it("writes data-hf-id back to disk on first serve", async () => {
+ const { readFileSync } = await import("node:fs");
+ const projectDir = createProjectDir();
+ const indexPath = join(projectDir, "index.html");
+ writeFileSync(
+ indexPath,
+ `hello
`,
+ );
+ const app = new Hono();
+ registerPreviewRoutes(app, createAdapter(projectDir));
+ await app.request("http://localhost/projects/demo/preview");
+ const onDisk = readFileSync(indexPath, "utf-8");
+ expect(onDisk).toContain('data-hf-id="hf-');
+ });
+});
diff --git a/packages/core/src/studio-api/routes/preview.ts b/packages/core/src/studio-api/routes/preview.ts
index 30da87ba6..ad74ec2f8 100644
--- a/packages/core/src/studio-api/routes/preview.ts
+++ b/packages/core/src/studio-api/routes/preview.ts
@@ -11,6 +11,7 @@ import {
createStudioMotionRenderBodyScript,
STUDIO_MOTION_PATH,
} from "../helpers/studioMotionRenderScript.js";
+import { ensureHfIds, persistHfIdsIfNeeded } from "../helpers/hfIdPersist.js";
const PROJECT_SIGNATURE_META = "hyperframes-project-signature";
const GSAP_CDN_VERSION = "3.15.0";
@@ -205,14 +206,19 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi
return new Response(null, { status: 304, headers: previewCacheHeaders(etag) });
}
+ // Normalize + persist data-hf-id to disk before bundle reads it. Idempotent.
+ const diskMain = resolveProjectMainHtml(project.dir, project.id);
+ const normalizedDisk = diskMain
+ ? persistHfIdsIfNeeded(join(project.dir, diskMain.compositionPath), diskMain.html)
+ : null;
+
try {
let bundled = await adapter.bundle(project.dir);
let mainCompositionPath = "index.html";
if (!bundled) {
- const main = resolveProjectMainHtml(project.dir, project.id);
- if (!main) return c.text("not found", 404);
- bundled = main.html;
- mainCompositionPath = main.compositionPath;
+ if (!diskMain || normalizedDisk === null) return c.text("not found", 404);
+ bundled = normalizedDisk;
+ mainCompositionPath = diskMain.compositionPath;
}
// Inject runtime if not already present (check URL pattern and bundler attribute)
@@ -233,21 +239,27 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi
}
bundled = injectStudioPreviewAugmentations(
- await transformPreviewHtml(bundled, adapter, project, mainCompositionPath),
+ ensureHfIds(await transformPreviewHtml(bundled, adapter, project, mainCompositionPath)),
adapter,
project.dir,
mainCompositionPath,
);
return c.html(bundled, 200, previewCacheHeaders(etag));
} catch {
- const main = resolveProjectMainHtml(project.dir, project.id);
- if (main) {
+ if (diskMain && normalizedDisk !== null) {
return c.html(
injectStudioPreviewAugmentations(
- await transformPreviewHtml(main.html, adapter, project, main.compositionPath),
+ ensureHfIds(
+ await transformPreviewHtml(
+ normalizedDisk,
+ adapter,
+ project,
+ diskMain.compositionPath,
+ ),
+ ),
adapter,
project.dir,
- main.compositionPath,
+ diskMain.compositionPath,
),
200,
previewCacheHeaders(etag),
@@ -284,7 +296,7 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi
const baseHref = `/api/projects/${project.id}/preview/`;
let html = buildSubCompositionHtml(project.dir, compPath, adapter.runtimeUrl, baseHref);
if (!html) return c.text("not found", 404);
- html = await transformPreviewHtml(html, adapter, project, compPath);
+ html = ensureHfIds(await transformPreviewHtml(html, adapter, project, compPath));
return c.html(
injectStudioPreviewAugmentations(html, adapter, project.dir, compPath),
200,
diff --git a/packages/studio/src/utils/sourcePatcher.test.ts b/packages/studio/src/utils/sourcePatcher.test.ts
index 7697d24e6..ee4ab514c 100644
--- a/packages/studio/src/utils/sourcePatcher.test.ts
+++ b/packages/studio/src/utils/sourcePatcher.test.ts
@@ -517,17 +517,88 @@ describe("motion attribute round-trip via sourcePatcher", () => {
});
});
-// T3 — id-based targeting (spec for R1).
-// R1 adds `hfId?: string` to PatchTarget and a `[data-hf-id="…"]` lookup branch
-// in findTagByTarget. Convert from it.todo to real assertions in the R1 PR.
+// T3 — id-based targeting (R1).
describe("T3 — hfId targeting (spec for R1)", () => {
- it.todo("updates inline style by data-hf-id");
+ it("updates inline style by data-hf-id", () => {
+ const html = `Hello
`;
+ const result = applyPatchByTarget(
+ html,
+ { hfId: "hf-x7k2" },
+ {
+ type: "inline-style",
+ property: "color",
+ value: "blue",
+ },
+ );
+ expect(result).toContain("color: blue");
+ expect(result).toContain('data-hf-id="hf-x7k2"');
+ });
- it.todo("updates text content by data-hf-id");
+ it("updates text content by data-hf-id", () => {
+ const html = `Old text
`;
+ const result = applyPatchByTarget(
+ html,
+ { hfId: "hf-a1b2" },
+ {
+ type: "text-content",
+ property: "",
+ value: "New text",
+ },
+ );
+ expect(result).toContain(">New text<");
+ });
- it.todo("updates attribute by data-hf-id");
+ it("updates attribute by data-hf-id", () => {
+ const html = ``;
+ const result = applyPatchByTarget(
+ html,
+ { hfId: "hf-c3d4" },
+ {
+ type: "attribute",
+ property: "start",
+ value: "2.5",
+ },
+ );
+ expect(result).toContain('data-start="2.5"');
+ });
+
+ it("data-hf-id attribute is preserved after a style patch", () => {
+ const html = `Hello
`;
+ const patched = applyPatchByTarget(
+ html,
+ { hfId: "hf-x7k2" },
+ {
+ type: "inline-style",
+ property: "color",
+ value: "blue",
+ },
+ );
+ expect(readAttributeByTarget(patched, { hfId: "hf-x7k2" }, "data-hf-id")).toBe("hf-x7k2");
+ });
- it.todo("data-hf-id attribute is preserved after a style patch");
+ it("hfId lookup falls through to selector when hfId not found", () => {
+ const html = `Hello
`;
+ const result = applyPatchByTarget(
+ html,
+ { hfId: "hf-missing", selector: ".headline" },
+ { type: "inline-style", property: "color", value: "blue" },
+ );
+ expect(result).toContain("color: blue");
+ });
- it.todo("hfId lookup falls through to selector when hfId not found");
+ it("hfId match is authoritative — selector is not used as a narrowing filter", () => {
+ // hfId matches h1; selector points at h2. hfId wins — patch lands on h1, h2 untouched.
+ const html = `A
B
`;
+ const result = applyPatchByTarget(
+ html,
+ { hfId: "hf-x7k2", selector: ".b" },
+ { type: "inline-style", property: "color", value: "blue" },
+ );
+ expect(result).toContain('data-hf-id="hf-x7k2"');
+ const h1End = result.indexOf("");
+ const bluePos = result.indexOf("color: blue");
+ expect(bluePos).toBeGreaterThan(-1);
+ expect(bluePos).toBeLessThan(h1End);
+ expect(result).toContain('B
');
+ });
});
diff --git a/packages/studio/src/utils/sourcePatcher.ts b/packages/studio/src/utils/sourcePatcher.ts
index 2d89eea2e..9d19114d4 100644
--- a/packages/studio/src/utils/sourcePatcher.ts
+++ b/packages/studio/src/utils/sourcePatcher.ts
@@ -94,6 +94,7 @@ export interface PatchOperation {
export interface PatchTarget {
id?: string | null;
+ hfId?: string;
selector?: string;
selectorIndex?: number;
}
@@ -232,61 +233,67 @@ function replaceTagAtMatch(html: string, match: TagMatch, newTag: string): strin
return `${html.slice(0, match.start)}${newTag}${html.slice(match.end)}`;
}
-export function findTagByTarget(html: string, target: PatchTarget): TagMatch | null {
- if (target.id) {
- const idPattern = new RegExp(`(<[^>]*\\bid=(["'])${escapeRegex(target.id)}\\2[^>]*)>`, "i");
- const match = idPattern.exec(html);
- if (match?.index != null) {
+function execDataAttrPattern(html: string, attr: string, value: string): TagMatch | null {
+ const pattern = new RegExp(`(<[^>]*\\b${attr}=(["'])${escapeRegex(value)}\\2[^>]*)>`, "i");
+ const match = pattern.exec(html);
+ if (match?.index == null) return null;
+ // Defensive: a second exact match means a duplicate id/attr in the source
+ // (id drift). Don't silently patch the first while leaving the other stale —
+ // surface it. By the mint contract this should never fire.
+ const all = html.match(new RegExp(`<[^>]*\\b${attr}=(["'])${escapeRegex(value)}\\1[^>]*>`, "gi"));
+ if (all && all.length > 1) {
+ // eslint-disable-next-line no-console
+ console.warn(
+ `sourcePatcher: ${attr}="${value}" matched ${all.length} elements; patching the first. ids/attrs must be unique per document.`,
+ );
+ }
+ return { tag: match[1], start: match.index, end: match.index + match[1].length };
+}
+
+function findTagByClass(html: string, target: PatchTarget): TagMatch | null {
+ const classMatch = target.selector?.match(/^\.([a-zA-Z0-9_-]+)$/);
+ if (!classMatch) return null;
+ const cls = classMatch[1];
+ const pattern = new RegExp(
+ `(<[^>]*\\bclass=(["'])[^"']*\\b${escapeRegex(cls)}\\b[^"']*\\2[^>]*)>`,
+ "gi",
+ );
+ const selectorIndex = target.selectorIndex ?? 0;
+ let match: RegExpExecArray | null;
+ let currentIndex = 0;
+ while ((match = pattern.exec(html)) !== null) {
+ if (currentIndex === selectorIndex && match.index != null) {
return {
tag: match[1],
start: match.index,
end: match.index + match[1].length,
};
}
+ currentIndex += 1;
+ }
+ return null;
+}
+
+export function findTagByTarget(html: string, target: PatchTarget): TagMatch | null {
+ if (target.hfId) {
+ const result = execDataAttrPattern(html, "data-hf-id", target.hfId);
+ if (result) return result;
+ }
+
+ if (target.id) {
+ const result = execDataAttrPattern(html, "id", target.id);
+ if (result) return result;
}
if (!target.selector) return null;
const compositionIdMatch = target.selector.match(/^\[data-composition-id="([^"]+)"\]$/);
if (compositionIdMatch) {
- const compId = compositionIdMatch[1];
- const pattern = new RegExp(
- `(<[^>]*\\bdata-composition-id=(["'])${escapeRegex(compId)}\\2[^>]*)>`,
- "i",
- );
- const match = pattern.exec(html);
- if (match?.index != null) {
- return {
- tag: match[1],
- start: match.index,
- end: match.index + match[1].length,
- };
- }
+ const result = execDataAttrPattern(html, "data-composition-id", compositionIdMatch[1]);
+ if (result) return result;
}
- const classMatch = target.selector.match(/^\.([a-zA-Z0-9_-]+)$/);
- if (classMatch) {
- const cls = classMatch[1];
- const pattern = new RegExp(
- `(<[^>]*\\bclass=(["'])[^"']*\\b${escapeRegex(cls)}\\b[^"']*\\2[^>]*)>`,
- "gi",
- );
- const selectorIndex = target.selectorIndex ?? 0;
- let match: RegExpExecArray | null;
- let currentIndex = 0;
- while ((match = pattern.exec(html)) !== null) {
- if (currentIndex === selectorIndex && match.index != null) {
- return {
- tag: match[1],
- start: match.index,
- end: match.index + match[1].length,
- };
- }
- currentIndex += 1;
- }
- }
-
- return null;
+ return findTagByClass(html, target);
}
export function readAttributeByTarget(