From 1c05e02d94a9c92cfaf43bab1fee499dc7543dbc Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Sat, 5 Sep 2026 17:18:57 +0530 Subject: [PATCH] Preserve supported MJML button appearance on import --- packages/email-renderer/package.json | 2 +- packages/email-renderer/src/mjml.ts | 24 ++++++++++-- .../test/mjml-button-import.test.ts | 37 +++++++++++++++++++ 3 files changed, 59 insertions(+), 4 deletions(-) create mode 100644 packages/email-renderer/test/mjml-button-import.test.ts diff --git a/packages/email-renderer/package.json b/packages/email-renderer/package.json index c83ce02a..e535af8f 100644 --- a/packages/email-renderer/package.json +++ b/packages/email-renderer/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/email-renderer", - "version": "0.1.32", + "version": "0.1.33", "type": "module", "description": "WRNexus block email renderers with HTML, text, AMP Email, and MJML interoperability.", "exports": { diff --git a/packages/email-renderer/src/mjml.ts b/packages/email-renderer/src/mjml.ts index 9d44cfac..1d693777 100644 --- a/packages/email-renderer/src/mjml.ts +++ b/packages/email-renderer/src/mjml.ts @@ -250,17 +250,35 @@ export function importMjml( slots: {}, }, ]; - if (node.tagName === "mj-button") + if (node.tagName === "mj-button") { + const buttonStyles: BlockNode["styles"] = {}; + const mappings: Record = { + align: ["textAlign", /^(left|center|right|start|end)$/], + "background-color": ["backgroundColor", /^(#[0-9a-f]{3,8}|rgba?\([0-9., %]+\))$/i], + color: ["color", /^(#[0-9a-f]{3,8}|rgba?\([0-9., %]+\))$/i], + "font-size": ["fontSize", /^\d+(?:\.\d+)?(?:px|rem|em|%)$/], + "font-weight": ["fontWeight", /^(normal|bold|[1-9]00)$/], + "border-radius": ["borderRadius", /^\d+(?:\.\d+)?(?:px|rem|em|%)$/], + }; + for (const [attribute, raw] of Object.entries(a)) { + if (attribute === "href") continue; + const mapping = mappings[attribute]; + const value = decode(raw).trim(); + if (!mapping || value.length > 80 || !mapping[1].test(value)) { + errors.push({ code: "mjml.button-attribute", message: `Unsupported button attribute or value: ${attribute}.`, severity: "error", element: node.tagName, attribute }); + } else buttonStyles[mapping[0]] = { base: value }; + } return [ { id, type: "content.button", version: 1, - props: { label: node.content ?? "Button", href: a.href ?? "" }, - styles: {}, + props: { label: decode(node.content ?? "Button"), href: decode(a.href ?? "") }, + styles: buttonStyles, slots: {}, }, ]; + } if (node.tagName === "mj-divider") return [{ id, type: "content.divider", version: 1, props: {}, styles: {}, slots: {} }]; if (node.tagName === "mj-spacer") diff --git a/packages/email-renderer/test/mjml-button-import.test.ts b/packages/email-renderer/test/mjml-button-import.test.ts new file mode 100644 index 00000000..e4ee585d --- /dev/null +++ b/packages/email-renderer/test/mjml-button-import.test.ts @@ -0,0 +1,37 @@ +import { expect, test } from "bun:test"; +import type { BlockNode } from "@wrnexus/blocks"; +import { importMjml, renderEmail } from "../src/index.ts"; + +const source = (attributes: string) => `Buy & save !`; +const findButton = (node: BlockNode): BlockNode | undefined => node.type === "content.button" ? node : Object.values(node.slots).flat().map(findButton).find(Boolean); + +test("imported button preserves supported appearance and escaped label/link across export", async () => { + let id = 0; + const imported = importMjml(source('align="right" background-color="#123456" color="#abcdef" font-size="22px" font-weight="400" border-radius="12px"'), { id: () => `n${++id}` }); + expect(imported.ok).toBe(true); + if (!imported.ok) return; + const button = findButton(imported.value.root)!; + expect(button.props).toEqual({ label: "Buy & save !", href: "https://example.com/?a=1&b=2" }); + expect(button.styles).toEqual({ textAlign: { base: "right" }, backgroundColor: { base: "#123456" }, color: { base: "#abcdef" }, fontSize: { base: "22px" }, fontWeight: { base: "400" }, borderRadius: { base: "12px" } }); + const rendered = await renderEmail(imported.value, { mjml: true }); + expect(rendered.ok).toBe(true); + if (!rendered.ok) return; + expect(rendered.value.parts.html.content).toContain("font-size:22px;font-weight:400;border-radius:12px"); + expect(rendered.value.parts.text.content).toContain("Buy & save !"); + const again = importMjml(rendered.value.exports!.mjml!, { id: () => `n${++id}` }); + expect(again.ok).toBe(true); + if (again.ok) { + const next = findButton(again.value.root)!; + expect(next.props).toEqual(button.props); + expect(next.styles).toEqual(button.styles); + } +}); + +test("unsupported or unsafe button attributes produce explicit diagnostics", () => { + for (const attributes of ['color="red;position:fixed"', 'align="justify"', 'font-size="expression(1)"', 'onclick="alert(1)"', 'padding="20px"']) { + let id = 0; + const result = importMjml(source(attributes), { id: () => `n${++id}` }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.errors.some(error => error.code === "mjml.button-attribute")).toBe(true); + } +});