diff --git a/packages/studio/src/utils/sourcePatcher.test.ts b/packages/studio/src/utils/sourcePatcher.test.ts
index 7697d24e6..67c01b3a7 100644
--- a/packages/studio/src/utils/sourcePatcher.test.ts
+++ b/packages/studio/src/utils/sourcePatcher.test.ts
@@ -517,17 +517,72 @@ 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.todo("data-hf-id attribute is preserved after a style patch");
+ 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("hfId lookup falls through to selector when hfId not found");
+ 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");
+ });
});
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(