feat(email-renderer): add marketing and commerce blocks
This commit is contained in:
1 parent
edccf06658
commit
6cdd39ba38
3 files changed
+122
-1
No files matched your search
@@ -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": {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user