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(