Skip to content
Merged
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
68 changes: 37 additions & 31 deletions packages/core/src/studio-api/helpers/sourceMutation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,32 +46,33 @@ describe("patchElementInHtml", () => {
</body></html>`;

it("patches inline style by id", () => {
const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
const { html: result, matched } = patchElementInHtml(FIXTURE, { id: "hero" }, [
{ type: "inline-style", property: "color", value: "red" },
]);

expect(matched).toBe(true);
expect(result).toMatch(/color:\s*red/);
expect(result).toContain('id="hero"');
});

it("patches inline style by class selector", () => {
const result = patchElementInHtml(FIXTURE, { selector: ".hero-heading" }, [
const { html: result } = patchElementInHtml(FIXTURE, { selector: ".hero-heading" }, [
{ type: "inline-style", property: "font-size", value: "72px" },
]);

expect(result).toMatch(/font-size:\s*72px/);
});

it("patches data attribute", () => {
const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
const { html: result } = patchElementInHtml(FIXTURE, { id: "hero" }, [
{ type: "attribute", property: "hf-studio-path-offset", value: "true" },
]);

expect(result).toContain('data-hf-studio-path-offset="true"');
});

it("does not double data- prefix when property already has it", () => {
const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
const { html: result } = patchElementInHtml(FIXTURE, { id: "hero" }, [
{ type: "attribute", property: "data-hf-studio-path-offset", value: "true" },
]);

Expand All @@ -88,7 +89,7 @@ describe("patchElementInHtml", () => {
"data-hf-studio-rotation",
];
for (const attr of attrs) {
const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
const { html: result } = patchElementInHtml(FIXTURE, { id: "hero" }, [
{ type: "attribute", property: attr, value: "true" },
]);
expect(result).toContain(`${attr}="true"`);
Expand All @@ -97,27 +98,27 @@ describe("patchElementInHtml", () => {
});

it("removes attribute with data- prefix already present", () => {
const withAttr = patchElementInHtml(FIXTURE, { id: "hero" }, [
const { html: withAttr } = patchElementInHtml(FIXTURE, { id: "hero" }, [
{ type: "attribute", property: "data-hf-studio-path-offset", value: "true" },
]);
expect(withAttr).toContain('data-hf-studio-path-offset="true"');

const removed = patchElementInHtml(withAttr, { id: "hero" }, [
const { html: removed } = patchElementInHtml(withAttr, { id: "hero" }, [
{ type: "attribute", property: "data-hf-studio-path-offset", value: null },
]);
expect(removed).not.toContain("hf-studio-path-offset");
});

it("patches html attribute", () => {
const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
const { html: result } = patchElementInHtml(FIXTURE, { id: "hero" }, [
{ type: "html-attribute", property: "title", value: "greeting" },
]);

expect(result).toContain('title="greeting"');
});

it("patches text content", () => {
const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
const { html: result } = patchElementInHtml(FIXTURE, { id: "hero" }, [
{ type: "text-content", property: "", value: "New Title" },
]);

Expand All @@ -126,7 +127,7 @@ describe("patchElementInHtml", () => {
});

it("applies multiple operations in one call", () => {
const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
const { html: result } = patchElementInHtml(FIXTURE, { id: "hero" }, [
{ type: "inline-style", property: "color", value: "blue" },
{ type: "inline-style", property: "font-size", value: "96px" },
{ type: "attribute", property: "hf-studio-path-offset", value: "true" },
Expand All @@ -138,16 +139,18 @@ describe("patchElementInHtml", () => {
});

it("finds element by composition-id selector", () => {
const result = patchElementInHtml(FIXTURE, { selector: '[data-composition-id="overlay"]' }, [
{ type: "inline-style", property: "opacity", value: "0.5" },
]);
const { html: result } = patchElementInHtml(
FIXTURE,
{ selector: '[data-composition-id="overlay"]' },
[{ type: "inline-style", property: "opacity", value: "0.5" }],
);

expect(result).toMatch(/opacity:\s*0\.5/);
});

it("finds element by class with selectorIndex", () => {
const html = `<div class="item">A</div><div class="item">B</div>`;
const result = patchElementInHtml(html, { selector: ".item", selectorIndex: 1 }, [
const { html: result } = patchElementInHtml(html, { selector: ".item", selectorIndex: 1 }, [
{ type: "text-content", property: "", value: "Changed" },
]);

Expand All @@ -156,41 +159,44 @@ describe("patchElementInHtml", () => {
expect(result).not.toContain(">B<");
});

it("returns unchanged html when target not found", () => {
const result = patchElementInHtml(FIXTURE, { id: "nonexistent" }, [
it("returns unchanged html and matched:false when target not found", () => {
const { html: result, matched } = patchElementInHtml(FIXTURE, { id: "nonexistent" }, [
{ type: "inline-style", property: "color", value: "red" },
]);

expect(matched).toBe(false);
expect(result).toBe(FIXTURE);
});

it("removes inline style when value is null", () => {
const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
const { html: result } = patchElementInHtml(FIXTURE, { id: "hero" }, [
{ type: "inline-style", property: "font-size", value: null },
]);

expect(result).not.toContain("font-size");
});

it("removes attribute when value is null", () => {
const result = patchElementInHtml(FIXTURE, { selector: '[data-composition-id="overlay"]' }, [
{ type: "html-attribute", property: "data-composition-src", value: null },
]);
const { html: result } = patchElementInHtml(
FIXTURE,
{ selector: '[data-composition-id="overlay"]' },
[{ type: "html-attribute", property: "data-composition-src", value: null }],
);

expect(result).not.toContain("data-composition-src");
});

it("patches fragment html without doctype", () => {
const fragment = `<div id="card" style="padding: 8px"><span>Title</span></div>`;
const result = patchElementInHtml(fragment, { id: "card" }, [
const { html: result } = patchElementInHtml(fragment, { id: "card" }, [
{ type: "inline-style", property: "padding", value: "16px" },
]);

expect(result).toMatch(/padding:\s*16px/);
});

it("rejects event handler attributes", () => {
const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
const { html: result } = patchElementInHtml(FIXTURE, { id: "hero" }, [
{ type: "html-attribute", property: "onload", value: "fetch('/evil')" },
]);

Expand All @@ -199,15 +205,15 @@ describe("patchElementInHtml", () => {
});

it("rejects javascript: URLs in src", () => {
const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
const { html: result } = patchElementInHtml(FIXTURE, { id: "hero" }, [
{ type: "html-attribute", property: "src", value: "javascript:alert(1)" },
]);

expect(result).not.toContain("javascript:");
});

it("allows aria-* and data-* attributes", () => {
const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
const { html: result } = patchElementInHtml(FIXTURE, { id: "hero" }, [
{ type: "html-attribute", property: "aria-label", value: "greeting" },
{ type: "html-attribute", property: "data-custom", value: "test" },
]);
Expand All @@ -217,7 +223,7 @@ describe("patchElementInHtml", () => {
});

it("rejects srcdoc and formaction attributes", () => {
const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
const { html: result } = patchElementInHtml(FIXTURE, { id: "hero" }, [
{ type: "html-attribute", property: "srcdoc", value: "<script>alert(1)</script>" },
{ type: "html-attribute", property: "formaction", value: "javascript:void(0)" },
]);
Expand All @@ -227,7 +233,7 @@ describe("patchElementInHtml", () => {
});

it("rejects on* event handlers regardless of casing", () => {
const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
const { html: result } = patchElementInHtml(FIXTURE, { id: "hero" }, [
{ type: "html-attribute", property: "onClick", value: "alert(1)" },
{ type: "html-attribute", property: "ONERROR", value: "alert(2)" },
{ type: "html-attribute", property: "onmouseover", value: "alert(3)" },
Expand All @@ -237,7 +243,7 @@ describe("patchElementInHtml", () => {
});

it("rejects data:text/html URIs in src", () => {
const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
const { html: result } = patchElementInHtml(FIXTURE, { id: "hero" }, [
{
type: "html-attribute",
property: "src",
Expand All @@ -249,23 +255,23 @@ describe("patchElementInHtml", () => {
});

it("allows safe href values", () => {
const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
const { html: result } = patchElementInHtml(FIXTURE, { id: "hero" }, [
{ type: "html-attribute", property: "href", value: "https://example.com" },
]);

expect(result).toContain('href="https://example.com"');
});

it("rejects javascript: in href", () => {
const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
const { html: result } = patchElementInHtml(FIXTURE, { id: "hero" }, [
{ type: "html-attribute", property: "href", value: "javascript:alert(1)" },
]);

expect(result).not.toContain("javascript:");
});

it("allows legitimate form and media attributes", () => {
const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
const { html: result } = patchElementInHtml(FIXTURE, { id: "hero" }, [
{ type: "html-attribute", property: "placeholder", value: "Enter text" },
{ type: "html-attribute", property: "target", value: "_blank" },
{ type: "html-attribute", property: "rel", value: "noopener" },
Expand All @@ -279,7 +285,7 @@ describe("patchElementInHtml", () => {
});

it("rejects unknown/dangerous attributes", () => {
const result = patchElementInHtml(FIXTURE, { id: "hero" }, [
const { html: result } = patchElementInHtml(FIXTURE, { id: "hero" }, [
{ type: "html-attribute", property: "xmlns", value: "http://evil.com" },
{ type: "html-attribute", property: "background", value: "http://evil.com/bg.js" },
{ type: "html-attribute", property: "dynsrc", value: "http://evil.com/vid.avi" },
Expand Down
9 changes: 6 additions & 3 deletions packages/core/src/studio-api/helpers/sourceMutation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,10 +160,10 @@ export function patchElementInHtml(
source: string,
target: SourceMutationTarget,
operations: PatchOperation[],
): string {
): { html: string; matched: boolean } {
const { document, wrappedFragment } = parseSourceDocument(source);
const el = findTargetElement(document, target);
if (!el || !isHTMLElement(el)) return source;
if (!el || !isHTMLElement(el)) return { html: source, matched: false };
const htmlEl = el as unknown as HTMLElement;

for (const op of operations) {
Expand Down Expand Up @@ -200,7 +200,10 @@ export function patchElementInHtml(
}
}

return wrappedFragment ? document.body.innerHTML || "" : document.toString();
return {
html: wrappedFragment ? document.body.innerHTML || "" : document.toString(),
matched: true,
};
}

export function probeElementInSource(source: string, target: SourceMutationTarget): boolean {
Expand Down
12 changes: 8 additions & 4 deletions packages/core/src/studio-api/routes/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,12 +335,16 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
} catch {
return c.json({ error: "not found" }, 404);
}
return writeIfChanged(
c,
ctx.absPath,
const { html: patched, matched } = patchElementInHtml(
originalContent,
patchElementInHtml(originalContent, parsed.target, parsed.body.operations),
parsed.target,
parsed.body.operations,
);
if (patched === originalContent) {
return c.json({ ok: true, changed: false, matched, content: originalContent });
}
writeFileSync(ctx.absPath, patched, "utf-8");
return c.json({ ok: true, changed: true, matched, content: patched });
});

api.post("/projects/:id/file-mutations/probe-element/*", async (c) => {
Expand Down
23 changes: 21 additions & 2 deletions packages/studio/src/hooks/useDomEditCommits.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback } from "react";
import { useCallback, useRef } from "react";
import { usePlayerStore } from "../player";
import { FONT_EXT } from "../utils/mediaTypes";
import type { PatchOperation } from "../utils/sourcePatcher";
Expand Down Expand Up @@ -128,6 +128,8 @@ export function useDomEditCommits({
[fileTree, projectId, importedFontAssetsRef],
);

const reportedUnresolvableRef = useRef(new Set<string>());

// fallow-ignore-next-line complexity
const persistDomEditOperations: PersistDomEditOperations = useCallback(
async (selection, operations, options) => {
Expand Down Expand Up @@ -173,11 +175,28 @@ export function useDomEditCommits({
const patchData = (await patchResponse.json()) as {
ok?: boolean;
changed?: boolean;
matched?: boolean;
content?: string;
};

if (!patchData.changed) {
throw new Error(`Unable to patch ${selection.selector ?? selection.id ?? "selection"}`);
if (patchData.matched === false) {
const targetKey = selection.selector ?? selection.id ?? "selection";
if (!reportedUnresolvableRef.current.has(targetKey)) {
reportedUnresolvableRef.current.add(targetKey);
trackStudioEvent("save_skipped_unresolvable", {
target_id: selection.id ?? undefined,
target_selector: selection.selector ?? undefined,
target_source_file: selection.sourceFile ?? undefined,
composition: activeCompPath ?? undefined,
});
console.warn(
`[studio] Element not found in source: ${targetKey}. ` +
"This element may be generated at runtime and cannot be persisted.",
);
}
}
return;
}

const patchedContent =
Expand Down
Loading