diff --git a/packages/email-renderer/package.json b/packages/email-renderer/package.json index 3aa80b99..2cf80f48 100644 --- a/packages/email-renderer/package.json +++ b/packages/email-renderer/package.json @@ -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": { diff --git a/packages/email-renderer/src/blocks.ts b/packages/email-renderer/src/blocks.ts index f0a4b02b..0dfc737c 100644 --- a/packages/email-renderer/src/blocks.ts +++ b/packages/email-renderer/src/blocks.ts @@ -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; +const imageDimension = (fallback: string): ValueSchema => ({ + 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 ``; + if (target === "mjml") return ``; + return `${alt}`; +}; const styleBase = (node: BlockNode, key: string) => { const value = (node.styles as Record)[key]; return value && typeof value === "object" && !Array.isArray(value) @@ -99,7 +118,7 @@ export const emailDocumentBlock = defineBlock({ ampEmail: ({ node, slots }) => ``, mjml: ({ node, slots }) => - `${slots.content}`, + `${slots.content}`, }, 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 ? `` : ""}

${html(attrs(node).title)}

${html(attrs(node).summary)}

`, node), - ampEmail: ({ node }) => emailCell(`

${html(attrs(node).title)}

${html(attrs(node).summary)}

`, node), - mjml: ({ node }) => `${html(attrs(node).title)}${html(attrs(node).summary)}`, + emailHtml: ({ node }) => emailCell(`${marketingImage(node, "html")}

${html(attrs(node).title)}

${html(attrs(node).summary)}

`, node), + ampEmail: ({ node }) => emailCell(`${marketingImage(node, "amp")}

${html(attrs(node).title)}

${html(attrs(node).summary)}

`, node), + mjml: ({ node }) => `${marketingImage(node, "mjml")}${html(attrs(node).title)}${html(attrs(node).summary)}`, }, ({ 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(`${html(attrs(node).title)}

${html(attrs(node).title)}

${html(attrs(node).price)}

${html(attrs(node).buttonLabel)}

`, node), - ampEmail: ({ node }) => emailCell(`

${html(attrs(node).title)}

${html(attrs(node).price)}

${html(attrs(node).buttonLabel)}

`, node), - mjml: ({ node }) => `${html(attrs(node).title)}${html(attrs(node).price)}${html(attrs(node).buttonLabel)}`, + emailHtml: ({ node }) => emailCell(`${marketingImage(node, "html")}

${html(attrs(node).title)}

${html(attrs(node).price)}

${html(attrs(node).buttonLabel)}

`, node), + ampEmail: ({ node }) => emailCell(`${marketingImage(node, "amp")}

${html(attrs(node).title)}

${html(attrs(node).price)}

${html(attrs(node).buttonLabel)}

`, node), + mjml: ({ node }) => `${marketingImage(node, "mjml")}${html(attrs(node).title)}${html(attrs(node).price)}${html(attrs(node).buttonLabel)}`, }, ({ node }) => `${String(attrs(node).title)} — ${String(attrs(node).price)}\n${String(attrs(node).buttonLabel)}: ${String(attrs(node).href)}\n\n`, ); diff --git a/packages/email-renderer/src/email.ts b/packages/email-renderer/src/email.ts index cba3dc18..2c8df9c1 100644 --- a/packages/email-renderer/src/email.ts +++ b/packages/email-renderer/src/email.ts @@ -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; - 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 `${body}`; }; diff --git a/packages/email-renderer/test/marketing-images.test.ts b/packages/email-renderer/test/marketing-images.test.ts new file mode 100644 index 00000000..dcab915e --- /dev/null +++ b/packages/email-renderer/test/marketing-images.test.ts @@ -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): 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 = + 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 & 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(" { + 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); + } +});