diff --git a/packages/sdk/src/engine/model.ts b/packages/sdk/src/engine/model.ts index 6c61b9e41..58a0cb361 100644 --- a/packages/sdk/src/engine/model.ts +++ b/packages/sdk/src/engine/model.ts @@ -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 { const result: Record = {}; - 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; } diff --git a/packages/sdk/src/engine/mutate.test.ts b/packages/sdk/src/engine/mutate.test.ts index 4334787c4..c89f06359 100644 --- a/packages/sdk/src/engine/mutate.test.ts +++ b/packages/sdk/src/engine/mutate.test.ts @@ -324,14 +324,14 @@ describe("removeElement", () => { // ─── setElementStyles (model helper) ────────────────────────────────────────── -describe("setElementStyles key normalization", () => { - function elWith(style: string): Element { - const parsed = parseMutable(`
`); - 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(`
`); + 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 }); @@ -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", () => {