From 6cdd39ba38b18c34a7efd1cc44ef74d4eacf58c5 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Sat, 5 Sep 2026 10:53:22 +0530 Subject: [PATCH] feat(email-renderer): add marketing and commerce blocks --- packages/email-renderer/package.json | 2 +- packages/email-renderer/src/blocks.ts | 101 +++++++++++++++++++++ packages/email-renderer/test/email.test.ts | 20 ++++ 3 files changed, 122 insertions(+), 1 deletion(-) diff --git a/packages/email-renderer/package.json b/packages/email-renderer/package.json index fb6b97e0..dfa18968 100644 --- a/packages/email-renderer/package.json +++ b/packages/email-renderer/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/email-renderer", - "version": "0.1.0", + "version": "0.1.1", "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 56ec138c..8d744cf2 100644 --- a/packages/email-renderer/src/blocks.ts +++ b/packages/email-renderer/src/blocks.ts @@ -326,6 +326,101 @@ export const spacerBlock = leaf( () => "\n", ); +export const videoBlock = leaf( + "content.video", + "Video", + "icon-[lucide--video]", + object({ + title: string({ min: 1, max: 300, default: "Watch video" }), + href: safeUrl({ schemes: ["https"] }), + thumbnail: safeUrl({ schemes: ["https"] }), + alt: string({ max: 1_000, default: "Video thumbnail" }), + width: string({ default: "600" }), + height: string({ default: "338" }), + }) as never, + { + emailHtml: ({ node }) => emailCell(`${html(attrs(node).alt)}`, node), + ampEmail: ({ node }) => emailCell(``, node), + mjml: ({ node }) => ``, + }, + ({ node }) => `${String(attrs(node).title)}: ${String(attrs(node).href)}\n\n`, +); + +export const socialBlock = leaf( + "content.social", + "Social links", + "icon-[lucide--share-2]", + object({ + label: string({ max: 200, default: "Follow us" }), + facebook: safeUrl({ schemes: ["https"], optional: true }), + instagram: safeUrl({ schemes: ["https"], optional: true }), + linkedin: safeUrl({ schemes: ["https"], optional: true }), + x: safeUrl({ schemes: ["https"], optional: true }), + }) as never, + { + emailHtml: ({ node }) => emailCell(`

${html(attrs(node).label)}: ${[["Facebook",attrs(node).facebook],["Instagram",attrs(node).instagram],["LinkedIn",attrs(node).linkedin],["X",attrs(node).x]].filter(([,url])=>url).map(([name,url])=>`${name}`).join("")}

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

${html(attrs(node).label)}: ${[["Facebook",attrs(node).facebook],["Instagram",attrs(node).instagram],["LinkedIn",attrs(node).linkedin],["X",attrs(node).x]].filter(([,url])=>url).map(([name,url])=>`${name}`).join(" ")}

`, node), + mjml: ({ node }) => `${html(attrs(node).label)}: ${[["Facebook",attrs(node).facebook],["Instagram",attrs(node).instagram],["LinkedIn",attrs(node).linkedin],["X",attrs(node).x]].filter(([,url])=>url).map(([name,url])=>`${name}`).join(" ")}`, + }, + ({ node }) => `${String(attrs(node).label)}: ${[["Facebook",attrs(node).facebook],["Instagram",attrs(node).instagram],["LinkedIn",attrs(node).linkedin],["X",attrs(node).x]].filter(([,url])=>url).map(([name,url])=>`${name}: ${url}`).join(" · ")}\n\n`, +); + +export const countdownBlock = leaf( + "content.countdown", + "Countdown", + "icon-[lucide--timer]", + object({ + label: string({ max: 300, default: "Offer ends" }), + deadline: string({ min: 10, max: 40 }), + fallback: string({ max: 300, default: "This offer has ended." }), + }) as never, + { + emailHtml: ({ node }) => emailCell(`

${html(attrs(node).label)}:

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

${html(attrs(node).label)}:

`, node), + mjml: ({ node }) => `${html(attrs(node).label)}: ${html(attrs(node).deadline)}`, + }, + ({ node }) => `${String(attrs(node).label)}: ${String(attrs(node).deadline)}\n${String(attrs(node).fallback)}\n\n`, +); + +export const surveyBlock = leaf( + "content.survey", + "Survey", + "icon-[lucide--list-checks]", + object({ question: string({ min: 1, max: 500 }), buttonLabel: string({ min: 1, max: 200, default: "Answer survey" }), href: safeUrl({ schemes: ["https"] }) }) as never, + { + emailHtml: ({ node }) => emailCell(`

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

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

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

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

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

`, node), + mjml: ({ node }) => `${html(attrs(node).question)}${html(attrs(node).buttonLabel)}`, + }, + ({ node }) => `${String(attrs(node).question)}\n${String(attrs(node).buttonLabel)}: ${String(attrs(node).href)}\n\n`, +); + +export const feedItemBlock = leaf( + "content.feed-item", + "RSS article", + "icon-[lucide--rss]", + object({ 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)}`, + }, + ({ node }) => `${String(attrs(node).title)}\n${String(attrs(node).summary)}\n${String(attrs(node).href)}\n\n`, +); + +export const productBlock = leaf( + "commerce.product", + "Product", + "icon-[lucide--shopping-bag]", + object({ provider: choice(["shopify", "woocommerce", "manual"] as const, { default: "manual" }), 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)}`, + }, + ({ node }) => `${String(attrs(node).title)} — ${String(attrs(node).price)}\n${String(attrs(node).buttonLabel)}: ${String(attrs(node).href)}\n\n`, +); + export const emailCoreBlocks: readonly BlockDefinition[] = [ emailDocumentBlock, sectionBlock, @@ -337,4 +432,10 @@ export const emailCoreBlocks: readonly BlockDefinition[] = [ buttonBlock, dividerBlock, spacerBlock, + videoBlock, + socialBlock, + countdownBlock, + surveyBlock, + feedItemBlock, + productBlock, ]; diff --git a/packages/email-renderer/test/email.test.ts b/packages/email-renderer/test/email.test.ts index 328a85c0..f1535974 100644 --- a/packages/email-renderer/test/email.test.ts +++ b/packages/email-renderer/test/email.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { + emailCoreBlocks, renderEmail, validateAmpEmail, validateMjml, @@ -91,4 +92,23 @@ describe("email renderer", () => { (await validateMjml("x")).length, ).toBeGreaterThan(0); }); + + test("ships the complete marketing block pack", () => { + expect(emailCoreBlocks.map((block) => block.manifest.type)).toEqual( + expect.arrayContaining([ + "content.video", + "content.social", + "content.countdown", + "content.survey", + "content.feed-item", + "commerce.product", + ]), + ); + for (const block of emailCoreBlocks) { + expect(block.textRenderer).toBeFunction(); + expect(block.renderers?.emailHtml).toBeFunction(); + expect(block.renderers?.ampEmail).toBeFunction(); + expect(block.renderers?.mjml).toBeFunction(); + } + }); });