Preserve supported MJML button appearance on import
Quality / quality (ubuntu-latest) (push) Failing after 10m19s
Quality / quality (windows-latest) (push) Canceled after 0s

This commit is contained in:
Clintchiz committed 2026-09-05 17:18:57 +05:30
1 parent f5139f2c82
commit 1c05e02d94
3 files changed
+59 -4

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/email-renderer", "name": "@wrnexus/email-renderer",
"version": "0.1.32", "version": "0.1.33",
"type": "module", "type": "module",
"description": "WRNexus block email renderers with HTML, text, AMP Email, and MJML interoperability.", "description": "WRNexus block email renderers with HTML, text, AMP Email, and MJML interoperability.",
"exports": { "exports": {
+21 -3
View File
@@ -250,17 +250,35 @@ export function importMjml(
slots: {}, slots: {},
}, },
]; ];
if (node.tagName === "mj-button") if (node.tagName === "mj-button") {
const buttonStyles: BlockNode["styles"] = {};
const mappings: Record<string, [string, RegExp]> = {
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 [ return [
{ {
id, id,
type: "content.button", type: "content.button",
version: 1, version: 1,
props: { label: node.content ?? "Button", href: a.href ?? "" }, props: { label: decode(node.content ?? "Button"), href: decode(a.href ?? "") },
styles: {}, styles: buttonStyles,
slots: {}, slots: {},
}, },
]; ];
}
if (node.tagName === "mj-divider") if (node.tagName === "mj-divider")
return [{ id, type: "content.divider", version: 1, props: {}, styles: {}, slots: {} }]; return [{ id, type: "content.divider", version: 1, props: {}, styles: {}, slots: {} }];
if (node.tagName === "mj-spacer") if (node.tagName === "mj-spacer")
@@ -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) => `<mjml><mj-body><mj-section><mj-column><mj-button href="https://example.com/?a=1&amp;b=2" ${attributes}>Buy &amp; save &#33;</mj-button></mj-column></mj-section></mj-body></mjml>`;
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);
}
});