fix(email-renderer): preserve marketing images across output formats
Quality / quality (ubuntu-latest) (push) Failing after 9m58s
Quality / quality (windows-latest) (push) Canceled after 0s

This commit is contained in:
Clintchiz committed 2026-09-05 12:51:30 +05:30
1 parent 371c23db46
commit baee958c68
4 files changed
+144 -11

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/email-renderer",
"version": "0.1.13",
"version": "0.1.14",
"type": "module",
"description": "WRNexus block email renderers with HTML, text, AMP Email, and MJML interoperability.",
"exports": {
+28 -9
View File
@@ -9,6 +9,7 @@ import {
string,
type BlockDefinition,
type BlockNode,
type ValueSchema,
} from "@wrnexus/blocks";
import { brandColor, brandFont, brandWidth } from "./brand.ts";
@@ -31,6 +32,24 @@ const html = (value: unknown) =>
.replaceAll('"', """)
.replaceAll("'", "'");
const attrs = (node: BlockNode) => node.props as Record<string, unknown>;
const imageDimension = (fallback: string): ValueSchema<string> => ({
kind: "string",
defaultValue: fallback,
parse(value = fallback, path = "value") {
return typeof value === "string" && /^\d{1,4}$/.test(value) && Number(value) > 0 && Number(value) <= 4000
? { ok: true, value }
: { ok: false, errors: [{code:"email.image-dimension",message:"Image dimensions must be between 1 and 4000 pixels.",severity:"error",path}] };
},
});
const marketingImage = (node: BlockNode, target: "html" | "amp" | "mjml") => {
const p = attrs(node);
if (!p.image) return "";
const width = html(p.width ?? "600"), height = html(p.height ?? "400");
const src = html(p.image), alt = html(p.title);
if (target === "amp") return `<amp-img class="marketing-image" src="${src}" alt="${alt}" width="${width}" height="${height}" layout="responsive"></amp-img>`;
if (target === "mjml") return `<mj-image src="${src}" alt="${alt}" width="${width}px" />`;
return `<img src="${src}" alt="${alt}" width="${width}" style="display:block;height:auto;max-width:100%;" />`;
};
const styleBase = (node: BlockNode, key: string) => {
const value = (node.styles as Record<string, unknown>)[key];
return value && typeof value === "object" && !Array.isArray(value)
@@ -99,7 +118,7 @@ export const emailDocumentBlock = defineBlock({
ampEmail: ({ node, slots }) =>
`<table role="presentation" width="100%" class="email-bg"><tr><td align="center"><table role="presentation" width="${html(attrs(node).width)}" class="email-body">${slots.content}</table></td></tr></table>`,
mjml: ({ node, slots }) =>
`<mjml><mj-body width="${html(attrs(node).width)}px" background-color="${html(attrs(node).background)}">${slots.content}</mj-body></mjml>`,
`<mjml><mj-body width="${html(attrs(node).width ?? "600")}px" background-color="${html(attrs(node).background ?? "#f3f4f6")}">${slots.content}</mj-body></mjml>`,
},
textRenderer: ({ slots }) => slots.content,
} satisfies BlockDefinition);
@@ -455,11 +474,11 @@ export const feedItemBlock = leaf(
"content.feed-item",
"RSS article",
"icon-[lucide--rss]",
object({ feedUrl: safeUrl({ schemes: ["https"], optional: true }), title: string({ min: 1, max: 500 }), summary: string({ max: 2_000, default: "" }), href: safeUrl({ schemes: ["https"] }), image: safeUrl({ schemes: ["https"], optional: true }) }) as never,
object({ width: imageDimension("600"), height: imageDimension("400"), feedUrl: safeUrl({ schemes: ["https"], optional: true }), title: string({ min: 1, max: 500 }), summary: string({ max: 2_000, default: "" }), href: safeUrl({ schemes: ["https"] }), image: safeUrl({ schemes: ["https"], optional: true }) }) as never,
{
emailHtml: ({ node }) => emailCell(`${attrs(node).image ? `<img src="${html(attrs(node).image)}" alt="" style="display:block;height:auto;max-width:100%;" />` : ""}<h2 style="font-family:inherit;"><a href="${html(attrs(node).href)}">${html(attrs(node).title)}</a></h2><p style="font-family:inherit;">${html(attrs(node).summary)}</p>`, node),
ampEmail: ({ node }) => emailCell(`<h2><a href="${html(attrs(node).href)}">${html(attrs(node).title)}</a></h2><p>${html(attrs(node).summary)}</p>`, node),
mjml: ({ node }) => `<mj-text font-size="22px" font-weight="700"><a href="${html(attrs(node).href)}">${html(attrs(node).title)}</a></mj-text><mj-text>${html(attrs(node).summary)}</mj-text>`,
emailHtml: ({ node }) => emailCell(`${marketingImage(node, "html")}<h2 style="font-family:inherit;"><a href="${html(attrs(node).href)}">${html(attrs(node).title)}</a></h2><p style="font-family:inherit;">${html(attrs(node).summary)}</p>`, node),
ampEmail: ({ node }) => emailCell(`${marketingImage(node, "amp")}<h2><a href="${html(attrs(node).href)}">${html(attrs(node).title)}</a></h2><p>${html(attrs(node).summary)}</p>`, node),
mjml: ({ node }) => `${marketingImage(node, "mjml")}<mj-text font-size="22px" font-weight="700"><a href="${html(attrs(node).href)}">${html(attrs(node).title)}</a></mj-text><mj-text>${html(attrs(node).summary)}</mj-text>`,
},
({ node }) => `${String(attrs(node).title)}\n${String(attrs(node).summary)}\n${String(attrs(node).href)}\n\n`,
);
@@ -468,11 +487,11 @@ export const productBlock = leaf(
"commerce.product",
"Product",
"icon-[lucide--shopping-bag]",
object({ provider: choice(["shopify", "woocommerce", "manual"] as const, { default: "manual" }), storeUrl: safeUrl({ schemes: ["https"], optional: true }), productId: string({ max: 300, default: "" }), title: string({ min: 1, max: 500 }), price: string({ max: 100 }), image: safeUrl({ schemes: ["https"] }), href: safeUrl({ schemes: ["https"] }), buttonLabel: string({ max: 200, default: "Shop now" }) }) as never,
object({ width: imageDimension("600"), height: imageDimension("400"), provider: choice(["shopify", "woocommerce", "manual"] as const, { default: "manual" }), storeUrl: safeUrl({ schemes: ["https"], optional: true }), productId: string({ max: 300, default: "" }), title: string({ min: 1, max: 500 }), price: string({ max: 100 }), image: safeUrl({ schemes: ["https"] }), href: safeUrl({ schemes: ["https"] }), buttonLabel: string({ max: 200, default: "Shop now" }) }) as never,
{
emailHtml: ({ node }) => emailCell(`<img src="${html(attrs(node).image)}" alt="${html(attrs(node).title)}" style="display:block;height:auto;max-width:100%;" /><h2 style="font-family:inherit;">${html(attrs(node).title)}</h2><p style="font-family:inherit;font-weight:700;">${html(attrs(node).price)}</p><p><a href="${html(attrs(node).href)}">${html(attrs(node).buttonLabel)}</a></p>`, node),
ampEmail: ({ node }) => emailCell(`<h2>${html(attrs(node).title)}</h2><p>${html(attrs(node).price)}</p><p><a href="${html(attrs(node).href)}">${html(attrs(node).buttonLabel)}</a></p>`, node),
mjml: ({ node }) => `<mj-image src="${html(attrs(node).image)}" alt="${html(attrs(node).title)}" /><mj-text font-size="22px" font-weight="700">${html(attrs(node).title)}</mj-text><mj-text>${html(attrs(node).price)}</mj-text><mj-button href="${html(attrs(node).href)}">${html(attrs(node).buttonLabel)}</mj-button>`,
emailHtml: ({ node }) => emailCell(`${marketingImage(node, "html")}<h2 style="font-family:inherit;">${html(attrs(node).title)}</h2><p style="font-family:inherit;font-weight:700;">${html(attrs(node).price)}</p><p><a href="${html(attrs(node).href)}">${html(attrs(node).buttonLabel)}</a></p>`, node),
ampEmail: ({ node }) => emailCell(`${marketingImage(node, "amp")}<h2>${html(attrs(node).title)}</h2><p>${html(attrs(node).price)}</p><p><a href="${html(attrs(node).href)}">${html(attrs(node).buttonLabel)}</a></p>`, node),
mjml: ({ node }) => `${marketingImage(node, "mjml")}<mj-text font-size="22px" font-weight="700">${html(attrs(node).title)}</mj-text><mj-text>${html(attrs(node).price)}</mj-text><mj-button href="${html(attrs(node).href)}">${html(attrs(node).buttonLabel)}</mj-button>`,
},
({ node }) => `${String(attrs(node).title)} — ${String(attrs(node).price)}\n${String(attrs(node).buttonLabel)}: ${String(attrs(node).href)}\n\n`,
);
+1 -1
View File
@@ -81,7 +81,7 @@ const head = (document: BlockDocument, body: string, direction: "ltr" | "rtl") =
};
const ampHead = (document: BlockDocument, body: string, direction: "ltr" | "rtl") => {
const props = document.root.props as Record<string, unknown>;
const css = `body{margin:0;color:${props.textColor ?? "#111827"};font-family:${props.fontFamily ?? "Arial, sans-serif"}}a{color:${props.linkColor ?? "#2563eb"}}.email-bg{background:${props.background ?? "#f3f4f6"}}.email-body{max-width:${props.width ?? "600"}px;width:100%;background:${props.contentBackground ?? "#ffffff"}}`;
const css = `.marketing-image img{object-fit:contain}body{margin:0;color:${props.textColor ?? "#111827"};font-family:${props.fontFamily ?? "Arial, sans-serif"}}a{color:${props.linkColor ?? "#2563eb"}}.email-bg{background:${props.background ?? "#f3f4f6"}}.email-body{max-width:${props.width ?? "600"}px;width:100%;background:${props.contentBackground ?? "#ffffff"}}`;
return `<!doctype html><html amp4email data-css-strict lang="${escapeHtml(document.meta.lang)}" dir="${direction}"><head><meta charset="utf-8"><script async src="https://cdn.ampproject.org/v0.js"></script><style amp4email-boilerplate>body{visibility:hidden}</style><style amp-custom>${css}.section{padding:24px}.button{background:#2563eb;border-radius:6px;color:#fff;display:inline-block;font-weight:700;padding:12px 20px;text-decoration:none}.spacer{height:24px}</style></head><body>${body}</body></html>`;
};
@@ -0,0 +1,114 @@
import { expect, test } from "bun:test";
import type { BlockDocument } from "@wrnexus/blocks";
import { renderEmail, validateMjml } from "../src/index.ts";
function document(type: string, props: Record<string, string>): BlockDocument {
return {
schemaVersion: 1,
kind: "email",
meta: { subject: "Store update", lang: "en" },
theme: { tokens: {} },
root: {
id: "root",
type: "layout.email-document",
version: 1,
props: { width: "600" },
styles: {},
slots: {
content: [
{
id: "section",
type: "layout.section",
version: 1,
props: { background: "#ffffff", padding: "24px" },
styles: {},
slots: { content: [{ id: "item", type, version: 1, props, styles: {}, slots: {} }] },
},
],
},
},
};
}
test("product and RSS images survive HTML, AMP and strict MJML exports", async () => {
for (const type of ["commerce.product", "content.feed-item"]) {
const props: Record<string, string> =
type === "commerce.product"
? {
title: "Tea & coffee",
image: "https://example.com/product.png",
href: "https://example.com/product",
price: "$12.00",
buttonLabel: "Shop now",
width: "320",
height: "180",
}
: {
title: "Tea & coffee",
image: "https://example.com/article.png",
href: "https://example.com/article",
summary: "Latest news",
width: "320",
height: "180",
};
const result = await renderEmail(document(type, props), { amp: true, mjml: true });
expect(result.ok).toBe(true);
if (!result.ok) continue;
for (const output of [
result.value.parts.html.content,
result.value.parts.amp?.content,
result.value.exports?.mjml,
]) {
expect(output).toContain(props.image);
expect(output).toContain(props.href);
expect(output).toContain("Tea &amp; coffee");
if (type === "commerce.product") expect(output).toContain("$12.00");
}
expect(result.value.parts.amp?.validation.valid).toBe(true);
expect(result.value.parts.amp?.content).toContain(
'width="320" height="180" layout="responsive"',
);
expect(result.value.parts.amp?.content).toContain(".marketing-image img{object-fit:contain}");
expect(await validateMjml(result.value.exports!.mjml!)).toEqual([]);
}
});
test("image-free RSS and older product documents remain renderable", async () => {
const rss = await renderEmail(
document("content.feed-item", {
title: "News",
summary: "Update",
href: "https://example.com",
image: "",
}),
{ amp: true, mjml: true },
);
expect(rss.ok).toBe(true);
if (rss.ok) expect(rss.value.parts.amp?.content).not.toContain("<amp-img");
const product = await renderEmail(
document("commerce.product", {
title: "Product",
price: "12",
image: "https://example.com/p.png",
href: "https://example.com",
}),
{ amp: true },
);
expect(product.ok).toBe(true);
if (product.ok) expect(product.value.parts.amp?.content).toContain('width="600" height="400"');
});
test("marketing image dimensions reject invalid or injected values", async () => {
for (const width of ["0", "4001", "100%", '600" onerror="alert(1)']) {
const result = await renderEmail(
document("commerce.product", {
title: "Product",
price: "12",
image: "https://example.com/p.png",
href: "https://example.com",
width,
}),
);
expect(result.ok).toBe(false);
}
});