From 7195a2c88240b4e52ef39f63b754a01b2eccc8fa Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sun, 7 Jun 2026 20:49:10 -0700 Subject: [PATCH 1/2] feat(studio): sourcePatcher data-hf-id targeting (R1, T3) --- .../studio/src/utils/sourcePatcher.test.ts | 71 ++++++++++++++-- packages/studio/src/utils/sourcePatcher.ts | 80 +++++++++---------- 2 files changed, 102 insertions(+), 49 deletions(-) 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..e081622e7 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,58 @@ 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); + return match?.index != null + ? { tag: match[1], start: match.index, end: match.index + match[1].length } + : null; +} + +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( From 01470a0dc983894228f49e5c65fe7036f887137e Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Mon, 8 Jun 2026 15:20:08 -0700 Subject: [PATCH 2/2] fix(studio): warn on duplicate match in execDataAttrPattern (R1, T3 review) Addresses Rames' review on #1271: execDataAttrPattern returned the first regex match without checking for a second. A duplicate id/data-hf-id in source (id drift) would silently patch one element and leave the other stale. Now warns when more than one element matches. By the mint contract it should never fire. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/studio/src/utils/sourcePatcher.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/studio/src/utils/sourcePatcher.ts b/packages/studio/src/utils/sourcePatcher.ts index e081622e7..9d19114d4 100644 --- a/packages/studio/src/utils/sourcePatcher.ts +++ b/packages/studio/src/utils/sourcePatcher.ts @@ -236,9 +236,18 @@ function replaceTagAtMatch(html: string, match: TagMatch, newTag: string): strin 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); - return match?.index != null - ? { tag: match[1], start: match.index, end: match.index + match[1].length } - : null; + 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 {