Skip to content
Closed
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
32 changes: 29 additions & 3 deletions packages/sdk/src/engine/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,16 +138,42 @@ function toKebab(prop: string): string {
}

/** Parse style attribute string → camelCase map (custom props kept as-is). */
// fallow-ignore-next-line complexity
function parseStyleAttr(styleAttr: string): Record<string, string> {
const result: Record<string, string> = {};
for (const decl of styleAttr.split(";")) {
const flush = (decl: string): void => {
const idx = decl.indexOf(":");
if (idx === -1) continue;
if (idx === -1) return;
const rawProp = decl.slice(0, idx).trim();
const value = decl.slice(idx + 1).trim();
if (!rawProp || !value) continue;
if (!rawProp || !value) return;
result[toCamel(rawProp)] = value;
};
// Split on ';' only when outside quotes and balanced parens, so a value that
// carries a semicolon survives intact: data URIs, url(), and quoted strings
// (`url("data:image/svg+xml;base64,…")`, `content: "a;b"`). A naive
// split(";") truncates those. Mirrors the same-package class-style tokenizer
// (cssWriter.ts parseDeclarations) and the server patchStyleAttrString path.
let depth = 0;
let quote: string | null = null;
let start = 0;
for (let i = 0; i < styleAttr.length; i++) {
const ch = styleAttr[i]!;
if (quote) {
if (ch === "\\") i++; // skip escaped char
else if (ch === quote) quote = null;
continue;
}
if (ch === '"' || ch === "'") quote = ch;
else if (ch === "(") depth++;
else if (ch === ")") depth = Math.max(0, depth - 1);
else if (ch === ";" && depth === 0) {
flush(styleAttr.slice(start, i));
start = i + 1;
}
}
// Trailing declaration; also recovers the tail if a quote was left unterminated.
flush(styleAttr.slice(start));
return result;
}

Expand Down
49 changes: 42 additions & 7 deletions packages/sdk/src/engine/mutate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,14 +324,14 @@ describe("removeElement", () => {

// ─── setElementStyles (model helper) ──────────────────────────────────────────

describe("setElementStyles key normalization", () => {
function elWith(style: string): Element {
const parsed = parseMutable(`<div data-hf-id="hf-x" data-hf-root style="${style}"></div>`);
const el = parsed.document.querySelector('[data-hf-id="hf-x"]');
if (!el) throw new Error("fixture element missing");
return el;
}
function elWith(style: string): Element {
const parsed = parseMutable(`<div data-hf-id="hf-x" data-hf-root style="${style}"></div>`);
const el = parsed.document.querySelector('[data-hf-id="hf-x"]');
if (!el) throw new Error("fixture element missing");
return el;
}

describe("setElementStyles key normalization", () => {
it("removes a hyphenated property when value is null", () => {
const el = elWith("transform-origin: center center; opacity: 0.5");
setElementStyles(el, { "transform-origin": null });
Expand Down Expand Up @@ -366,6 +366,41 @@ describe("setElementStyles key normalization", () => {
});
});

describe("setElementStyles preserves semicolons inside values", () => {
it("keeps a data-URI value intact when editing a sibling property", () => {
// A naive split(";") truncates the url() at the ';' inside the data URI,
// dropping the background when an unrelated property is edited. Mirrors the
// class-style coverage in mutate.cssstyle.test.ts.
const el = elWith(
"background: url(data:image/svg+xml;base64,PHN2Zz09) no-repeat; opacity: 0.5",
);
setElementStyles(el, { opacity: "1" });
const styles = getElementStyles(el);
expect(styles.background).toBe("url(data:image/svg+xml;base64,PHN2Zz09) no-repeat");
expect(styles.opacity).toBe("1");
expect(el.getAttribute("style")).toContain("base64,PHN2Zz09");
});

it("keeps a quoted value containing a semicolon intact", () => {
const el = elWith("content: 'a;b'; opacity: 0.5");
setElementStyles(el, { opacity: "1" });
const styles = getElementStyles(el);
expect(styles.content).toBe("'a;b'");
expect(styles.opacity).toBe("1");
});

it("removes a semicolon-bearing property cleanly", () => {
const el = elWith(
"background: url(data:image/svg+xml;base64,PHN2Zz09); opacity: 0.5",
);
setElementStyles(el, { background: null });
const styles = getElementStyles(el);
expect(styles.background).toBeUndefined();
expect(styles.opacity).toBe("0.5");
expect(el.getAttribute("style")).toBe("opacity: 0.5");
});
});

// ─── setVariableValue ─────────────────────────────────────────────────────────

describe("setVariableValue", () => {
Expand Down
Loading