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(`
`, 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).summary)}
`, node),
+ ampEmail: ({ node }) => emailCell(`${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(`.image)})
${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();
+ }
+ });
});