Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 63 additions & 8 deletions packages/studio/src/utils/sourcePatcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `<h1 data-hf-id="hf-x7k2" style="color: red">Hello</h1>`;
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 = `<p data-hf-id="hf-a1b2">Old text</p>`;
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 = `<div data-hf-id="hf-c3d4" data-start="0"></div>`;
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 = `<h1 data-hf-id="hf-x7k2" style="color: red">Hello</h1>`;
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 = `<h1 class="headline" style="color: red">Hello</h1>`;
const result = applyPatchByTarget(
html,
{ hfId: "hf-missing", selector: ".headline" },
{ type: "inline-style", property: "color", value: "blue" },
);
expect(result).toContain("color: blue");
});
});
89 changes: 48 additions & 41 deletions packages/studio/src/utils/sourcePatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ export interface PatchOperation {

export interface PatchTarget {
id?: string | null;
hfId?: string;
selector?: string;
selectorIndex?: number;
}
Expand Down Expand Up @@ -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(
Expand Down
Loading