feat(email-renderer): add marketing and commerce blocks
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 10:53:22 +05:30
1 parent edccf06658
commit 6cdd39ba38
3 files changed
+122 -1

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/email-renderer", "name": "@wrnexus/email-renderer",
"version": "0.1.0", "version": "0.1.1",
"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": {
+101
View File
@@ -326,6 +326,101 @@ export const spacerBlock = leaf(
() => "\n", () => "\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(`<a href="${html(attrs(node).href)}" aria-label="${html(attrs(node).title)}"><img src="${html(attrs(node).thumbnail)}" alt="${html(attrs(node).alt)}" width="${html(attrs(node).width)}" height="${html(attrs(node).height)}" style="border:0;display:block;height:auto;max-width:100%;" /></a>`, node),
ampEmail: ({ node }) => emailCell(`<a href="${html(attrs(node).href)}" aria-label="${html(attrs(node).title)}"><amp-img src="${html(attrs(node).thumbnail)}" alt="${html(attrs(node).alt)}" width="${html(attrs(node).width)}" height="${html(attrs(node).height)}" layout="responsive"></amp-img></a>`, node),
mjml: ({ node }) => `<mj-image href="${html(attrs(node).href)}" src="${html(attrs(node).thumbnail)}" alt="${html(attrs(node).alt)}" width="${html(attrs(node).width)}px" />`,
},
({ 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(`<p style="font-family:Arial,sans-serif;margin:0 0 16px;">${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])=>`<a href="${html(url)}" style="margin-right:12px;">${name}</a>`).join("")}</p>`, node),
ampEmail: ({ node }) => emailCell(`<p>${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])=>`<a href="${html(url)}">${name}</a>`).join(" ")}</p>`, node),
mjml: ({ node }) => `<mj-text>${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])=>`<a href="${html(url)}">${name}</a>`).join(" ")}</mj-text>`,
},
({ 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(`<p style="font-family:Arial,sans-serif;font-size:20px;font-weight:700;margin:0 0 16px;">${html(attrs(node).label)}: <time datetime="${html(attrs(node).deadline)}">${html(attrs(node).deadline)}</time></p>`, node),
ampEmail: ({ node }) => emailCell(`<p>${html(attrs(node).label)}: <time datetime="${html(attrs(node).deadline)}">${html(attrs(node).deadline)}</time></p>`, node),
mjml: ({ node }) => `<mj-text font-size="20px" font-weight="700">${html(attrs(node).label)}: ${html(attrs(node).deadline)}</mj-text>`,
},
({ 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(`<p style="font-family:Arial,sans-serif;margin:0 0 12px;">${html(attrs(node).question)}</p><p><a href="${html(attrs(node).href)}">${html(attrs(node).buttonLabel)}</a></p>`, node),
ampEmail: ({ node }) => emailCell(`<p>${html(attrs(node).question)}</p><p><a href="${html(attrs(node).href)}">${html(attrs(node).buttonLabel)}</a></p>`, node),
mjml: ({ node }) => `<mj-text>${html(attrs(node).question)}</mj-text><mj-button href="${html(attrs(node).href)}">${html(attrs(node).buttonLabel)}</mj-button>`,
},
({ 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 ? `<img src="${html(attrs(node).image)}" alt="" style="display:block;height:auto;max-width:100%;" />` : ""}<h2 style="font-family:Arial,sans-serif;"><a href="${html(attrs(node).href)}">${html(attrs(node).title)}</a></h2><p style="font-family:Arial,sans-serif;">${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>`,
},
({ 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(`<img src="${html(attrs(node).image)}" alt="${html(attrs(node).title)}" style="display:block;height:auto;max-width:100%;" /><h2 style="font-family:Arial,sans-serif;">${html(attrs(node).title)}</h2><p style="font-family:Arial,sans-serif;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>`,
},
({ 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[] = [ export const emailCoreBlocks: readonly BlockDefinition[] = [
emailDocumentBlock, emailDocumentBlock,
sectionBlock, sectionBlock,
@@ -337,4 +432,10 @@ export const emailCoreBlocks: readonly BlockDefinition[] = [
buttonBlock, buttonBlock,
dividerBlock, dividerBlock,
spacerBlock, spacerBlock,
videoBlock,
socialBlock,
countdownBlock,
surveyBlock,
feedItemBlock,
productBlock,
]; ];
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { import {
emailCoreBlocks,
renderEmail, renderEmail,
validateAmpEmail, validateAmpEmail,
validateMjml, validateMjml,
@@ -91,4 +92,23 @@ describe("email renderer", () => {
(await validateMjml("<mjml><mj-body><mj-text>x</mj-text></mj-body></mjml>")).length, (await validateMjml("<mjml><mj-body><mj-text>x</mj-text></mj-body></mjml>")).length,
).toBeGreaterThan(0); ).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();
}
});
}); });