Files
WRNexusJS/packages/email-renderer/src/blocks.ts
T
Clintchiz 07ed6b2596
Quality / quality (ubuntu-latest) (push) Failing after 1s
Quality / quality (windows-latest) (push) Canceled after 0s
Preserve image accessibility and dimensions in MJML
2026-09-05 17:54:05 +05:30

578 lines
29 KiB
TypeScript

import {
boolean,
choice,
defineBlock,
json,
object,
record,
safeUrl,
string,
type BlockDefinition,
type BlockNode,
type ValueSchema,
} from "@wrnexus/blocks";
import { brandColor, brandFont, brandWidth } from "./brand.ts";
const styles = record(json, { maxKeys: 50 }) as never;
const label = (key: string, fallback: string) => ({ key, fallback });
const empty = object({}) as never;
const contentSlot = {
label: label("blocks.slot.content", "Content"),
accepts: { capabilities: ["content"] },
maxChildren: 500,
orientation: "vertical" as const,
sortable: true,
};
const html = (value: unknown) =>
String(value ?? "")
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
const attrs = (node: BlockNode) => node.props as Record<string, unknown>;
const isEmailRootChild = (node: BlockNode, context: unknown) =>
(context as { rootBlockIds?: string[] } | undefined)?.rootBlockIds?.includes(node.id) ?? false;
const mjmlDocumentHead = (node: BlockNode, context: unknown) => {
const meta = context as { emailSubject?: string; emailPreheader?: string } | undefined;
return `<mj-head><mj-title>${html(meta?.emailSubject)}</mj-title>${meta?.emailPreheader ? `<mj-preview>${html(meta.emailPreheader)}</mj-preview>` : ""}<mj-attributes><mj-all font-family="${html(attrs(node).fontFamily ?? "Arial, sans-serif")}" /><mj-text color="${html(attrs(node).textColor ?? "#111827")}" /></mj-attributes></mj-head>`;
};
export const ampLayoutClass = (node: BlockNode) =>
"wrn-layout-" + Array.from(node.id, char => char.codePointAt(0)!.toString(16)).join("-");
export function ampLayoutCss(node: BlockNode, brandLinkColor?: string): string {
const props = attrs(node);
if (node.type === "content.button")
return `.button.${ampLayoutClass(node)}{background-color:${buttonBackground(node, { brandLinkColor })};${buttonVisualCss(node)}}`;
if (node.type === "layout.section") {
const background = safeCssValue(props.background, /^(#[0-9a-f]{3,8}|rgba?\([0-9., %]+\))$/i) || "#ffffff";
const padding = safeCssValue(props.padding, /^\d+(?:\.\d+)?(?:px|rem|em|%)?(?:\s+\d+(?:\.\d+)?(?:px|rem|em|%)?){0,3}$/) || "24px";
return `.${ampLayoutClass(node)}{background:${background};padding:${padding}}`;
}
if (node.type === "content.spacer") {
const height = safeCssValue(props.height, /^\d{1,4}$/) || "24";
return `.${ampLayoutClass(node)}{height:${height}px;line-height:${height}px;font-size:1px}`;
}
return "";
}
const multilineText = (value: unknown) => html(value).replace(/\r\n?|\n/g, "<br />");
const imageDimension = (fallback: string, minimum = 1): ValueSchema<string> => ({
kind: "string",
defaultValue: fallback,
parse(value = fallback, path = "value") {
return typeof value === "string" && /^\d{1,4}$/.test(value) && Number(value) >= minimum && Number(value) <= 4000
? { ok: true, value }
: { ok: false, errors: [{code:"email.image-dimension",message:`Dimensions must be between ${minimum} 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 `<amp-img class="marketing-image" src="${src}" alt="${alt}" width="${width}" height="${height}" layout="responsive"></amp-img>`;
if (target === "mjml") return `<mj-image src="${src}" alt="${alt}" width="${width}px" />`;
return `<img src="${src}" alt="${alt}" width="${width}" style="display:block;height:auto;width:100%;max-width:${width}px;" />`;
};
const styleBase = (node: BlockNode, key: string) => {
const value = (node.styles as Record<string, unknown>)[key];
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>).base
: undefined;
};
const safeCssValue = (value: unknown, pattern: RegExp) => {
const candidate = String(value ?? "").trim();
return candidate.length <= 80 && pattern.test(candidate) ? candidate : "";
};
const semanticStyle = (node: BlockNode) => {
const declarations: string[] = [];
const color = safeCssValue(styleBase(node, "color"), /^(#[0-9a-f]{3,8}|rgba?\([0-9., %]+\))$/i);
const background = safeCssValue(styleBase(node, "backgroundColor"), /^(#[0-9a-f]{3,8}|rgba?\([0-9., %]+\))$/i);
const padding = safeCssValue(styleBase(node, "padding"), /^\d+(?:\.\d+)?(?:px|rem|em|%)?(?:\s+\d+(?:\.\d+)?(?:px|rem|em|%)?){0,3}$/);
const margin = safeCssValue(styleBase(node, "margin"), /^\d+(?:\.\d+)?(?:px|rem|em|%)?(?:\s+\d+(?:\.\d+)?(?:px|rem|em|%)?){0,3}$/);
const fontSize = safeCssValue(styleBase(node, "fontSize"), /^\d+(?:\.\d+)?(?:px|rem|em|%)$/);
const radius = safeCssValue(styleBase(node, "borderRadius"), /^\d+(?:\.\d+)?(?:px|rem|em|%)$/);
const align = safeCssValue(styleBase(node, "textAlign"), /^(left|center|right|start|end)$/);
const weight = safeCssValue(styleBase(node, "fontWeight"), /^(normal|bold|[1-9]00)$/);
if (color) declarations.push(`color:${color}`);
if (background) declarations.push(`background-color:${background}`);
if (padding) declarations.push(`padding:${padding}`);
if (margin) declarations.push(`margin:${margin}`);
if (fontSize) declarations.push(`font-size:${fontSize}`);
if (radius) declarations.push(`border-radius:${radius}`);
if (align) declarations.push(`text-align:${align}`);
if (weight) declarations.push(`font-weight:${weight}`);
return declarations.join(";");
};
const buttonBackground = (node: BlockNode, context: unknown) => {
const override = safeCssValue(styleBase(node, "backgroundColor"), /^(#[0-9a-f]{3,8}|rgba?\([0-9., %]+\))$/i);
const brand = (context as { brandLinkColor?: string } | undefined)?.brandLinkColor;
return override || (brand && brandColor("#2563eb").parse(brand).ok ? brand : "#2563eb");
};
const buttonVisuals = (node: BlockNode) => ({
color: safeCssValue(styleBase(node, "color"), /^(#[0-9a-f]{3,8}|rgba?\([0-9., %]+\))$/i) || "#ffffff",
fontSize: safeCssValue(styleBase(node, "fontSize"), /^\d+(?:\.\d+)?(?:px|rem|em|%)$/) || "16px",
fontWeight: safeCssValue(styleBase(node, "fontWeight"), /^(normal|bold|[1-9]00)$/) || "700",
radius: safeCssValue(styleBase(node, "borderRadius"), /^\d+(?:\.\d+)?(?:px|rem|em|%)$/) || "6px",
});
const buttonVisualCss = (node: BlockNode) => {
const values = buttonVisuals(node);
return `color:${values.color};font-size:${values.fontSize};font-weight:${values.fontWeight};border-radius:${values.radius}`;
};
const buttonAlignment = (node: BlockNode, context: unknown) => {
const value = safeCssValue(styleBase(node, "textAlign"), /^(left|center|right|start|end)$/) || "start";
const rtl = (context as { direction?: string } | undefined)?.direction === "rtl";
return value === "start" ? (rtl ? "right" : "left") : value === "end" ? (rtl ? "left" : "right") : value;
};
const emailCell = (body: string, node: BlockNode, align?: string) =>
`<tr><td data-block-id="${html(node.id)}"${align ? ` align="${html(align)}"` : ""} style="padding:0;${semanticStyle(node)}">${body}</td></tr>`;
export const emailDocumentBlock = defineBlock({
manifest: {
type: "layout.email-document",
version: 1,
name: label("blocks.document.name", "Document"),
icon: "icon-[lucide--mail]",
category: "layout",
capabilities: ["root"],
props: object({
width: brandWidth,
background: brandColor("#f3f4f6"),
contentBackground: brandColor("#ffffff"),
fontFamily: brandFont,
textColor: brandColor("#111827"),
linkColor: brandColor("#2563eb"),
logoUrl: safeUrl({ schemes: ["https"], optional: true }),
logoAlt: string({ max: 300, default: "" }),
}) as never,
styles,
slots: { content: contentSlot },
},
create: ({ id }) => ({
id: id(),
type: "layout.email-document",
version: 1,
props: { width: "600", background: "#f3f4f6", contentBackground: "#ffffff", fontFamily: "Arial, sans-serif", textColor: "#111827", linkColor: "#2563eb", logoUrl: "", logoAlt: "" },
styles: {},
slots: { content: [] },
}),
renderers: {
emailHtml: ({ node, slots }) =>
`<table data-block-id="${html(node.id)}" role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;background:${html(attrs(node).background ?? "#f3f4f6")};color:${html(attrs(node).textColor ?? "#111827")};font-family:${html(attrs(node).fontFamily ?? "Arial, sans-serif")};"><tr><td align="center">${attrs(node).logoUrl ? `<img src="${html(attrs(node).logoUrl)}" alt="${html(attrs(node).logoAlt)}" style="display:block;height:auto;max-width:200px;padding:20px;" />` : ""}<table role="presentation" class="wrn-email" width="${html(attrs(node).width)}" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;max-width:100%;width:${html(attrs(node).width)}px;background:${html(attrs(node).contentBackground ?? "#ffffff")};color:${html(attrs(node).textColor ?? "#111827")};font-family:${html(attrs(node).fontFamily ?? "Arial, sans-serif")};">${slots.content}</table></td></tr></table>`,
ampEmail: ({ node, slots }) =>
`<table role="presentation" width="100%" class="email-bg"><tr><td align="center">${attrs(node).logoUrl ? `<amp-img class="marketing-image" src="${html(attrs(node).logoUrl)}" alt="${html(attrs(node).logoAlt)}" width="200" height="64" layout="fixed"></amp-img>` : ""}<table role="presentation" width="${html(attrs(node).width)}" class="email-body">${slots.content}</table></td></tr></table>`,
mjml: ({ node, slots, context }) =>
`<mjml>${mjmlDocumentHead(node, context)}<mj-body width="${html(attrs(node).width ?? "600")}px" background-color="${html(attrs(node).background ?? "#f3f4f6")}">${attrs(node).logoUrl ? `<mj-section padding="20px"><mj-column><mj-image src="${html(attrs(node).logoUrl)}" alt="${html(attrs(node).logoAlt)}" width="200px" padding="0" /></mj-column></mj-section>` : ""}${slots.content}</mj-body></mjml>`,
},
textRenderer: ({ slots }) => slots.content,
} satisfies BlockDefinition);
export const sectionBlock = defineBlock({
manifest: {
type: "layout.section",
version: 1,
name: label("blocks.section.name", "Section"),
icon: "icon-[lucide--panel-top]",
category: "layout",
capabilities: ["content"],
props: object({
background: string({ default: "#ffffff" }),
padding: string({ default: "24px" }),
}) as never,
styles,
slots: { content: contentSlot },
},
create: ({ id }) => ({
id: id(),
type: "layout.section",
version: 1,
props: { background: "#ffffff", padding: "24px" },
styles: {},
slots: { content: [] },
}),
renderers: {
emailHtml: ({ node, slots }) =>
emailCell(
`<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:${html(attrs(node).background ?? "#ffffff")};padding:${html(attrs(node).padding ?? "24px")};"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">${slots.content}</table></td></tr></table>`,
node,
),
ampEmail: ({ node, slots }) =>
emailCell(
`<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td class="${ampLayoutClass(node)}"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">${slots.content}</table></td></tr></table>`,
node,
),
mjml: ({ node, slots }) => {
const children = node.slots.content ?? [];
const body = children.length === 1 && children[0]?.type === "layout.columns"
? slots.content
: `<mj-column>${slots.content}</mj-column>`;
return `<mj-section background-color="${html(attrs(node).background ?? "#ffffff")}" padding="${html(attrs(node).padding ?? "24px")}">${body}</mj-section>`;
},
},
textRenderer: ({ slots }) => `${slots.content}\n`,
} satisfies BlockDefinition);
export const columnsBlock = defineBlock({
manifest: {
type: "layout.columns",
version: 1,
name: label("blocks.columns.name", "Columns"),
icon: "icon-[lucide--columns-3]",
category: "layout",
capabilities: ["content"],
props: empty,
styles,
slots: {
columns: {
accepts: { types: ["layout.column"] },
minChildren: 1,
maxChildren: 4,
orientation: "horizontal",
sortable: true,
},
},
},
create: ({ id }) => ({
id: id(),
type: "layout.columns",
version: 1,
props: {},
styles: {},
slots: { columns: [] },
}),
renderers: {
emailHtml: ({ node, slots }) =>
emailCell(
`<table role="presentation" width="100%"><tr class="wrn-columns">${slots.columns}</tr></table>`,
node,
),
ampEmail: ({ node, slots }) =>
emailCell(`<table role="presentation" width="100%"><tr class="wrn-columns">${slots.columns}</tr></table>`, node),
mjml: ({ node, slots, context }) => isEmailRootChild(node, context)
? `<mj-section padding="0">${slots.columns}</mj-section>`
: slots.columns,
},
textRenderer: ({ slots }) => slots.columns,
} satisfies BlockDefinition);
export const columnBlock = defineBlock({
manifest: {
type: "layout.column",
version: 1,
name: label("blocks.column.name", "Column"),
icon: "icon-[lucide--panel-left]",
category: "layout",
capabilities: ["column"],
props: object({ width: string({ default: "100%" }) }) as never,
styles,
slots: { content: contentSlot },
},
create: ({ id }) => ({
id: id(),
type: "layout.column",
version: 1,
props: { width: "100%" },
styles: {},
slots: { content: [] },
}),
renderers: {
emailHtml: ({ node, slots }) =>
`<td data-block-id="${html(node.id)}" class="wrn-column" width="${html(attrs(node).width)}" valign="top"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">${slots.content}</table></td>`,
ampEmail: ({ node, slots }) =>
`<td class="wrn-column" width="${html(attrs(node).width)}" valign="top"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">${slots.content}</table></td>`,
mjml: ({ node, slots }) =>
`<mj-column width="${html(attrs(node).width)}">${slots.content}</mj-column>`,
},
textRenderer: ({ slots }) => slots.content,
} satisfies BlockDefinition);
const leaf = (
type: string,
name: string,
icon: string,
props: never,
renderers: BlockDefinition["renderers"],
textRenderer: BlockDefinition["textRenderer"],
): BlockDefinition =>
defineBlock({
manifest: {
type,
version: 1,
name: label(`blocks.${type}.name`, name),
icon,
category: "content",
capabilities: ["content"],
props,
styles,
slots: {},
},
create: ({ id }) => ({ id: id(), type, version: 1, props: {}, styles: {}, slots: {} }),
renderers: {
...renderers,
...(renderers?.mjml ? { mjml: async (input) => {
const content = await renderers.mjml!(input);
return isEmailRootChild(input.node, input.context)
? `<mj-section padding="0"><mj-column padding="0">${content}</mj-column></mj-section>`
: content;
} } : {}),
},
textRenderer,
});
export const headingBlock = leaf(
"content.heading",
"Heading",
"icon-[lucide--heading]",
object({
text: string({ min: 1, max: 500, default: "Heading" }),
level: choice(["1", "2", "3", "4"] as const, { default: "2" }),
}) as never,
{
emailHtml: ({ node }) =>
emailCell(
`<h${attrs(node).level} style="font-family:inherit;margin:0 0 16px;">${html(attrs(node).text)}</h${attrs(node).level}>`,
node,
),
ampEmail: ({ node }) =>
emailCell(`<h${attrs(node).level}>${html(attrs(node).text)}</h${attrs(node).level}>`, node),
mjml: ({ node }) =>
`<mj-text font-size="24px" font-weight="700">${html(attrs(node).text)}</mj-text>`,
},
({ node }) => `${String(attrs(node).text)}\n\n`,
);
export const textBlock = leaf(
"content.text",
"Text",
"icon-[lucide--text]",
object({ text: string({ max: 200_000, default: "" }) }) as never,
{
emailHtml: ({ node }) =>
emailCell(
`<p style="font-family:inherit;line-height:1.5;margin:0 0 16px;">${multilineText(attrs(node).text)}</p>`,
node,
),
ampEmail: ({ node }) => emailCell(`<p>${multilineText(attrs(node).text)}</p>`, node),
mjml: ({ node }) => `<mj-text>${multilineText(attrs(node).text)}</mj-text>`,
},
({ node }) => `${String(attrs(node).text)}\n\n`,
);
export const imageBlock = leaf(
"content.image",
"Image",
"icon-[lucide--image]",
object({
src: safeUrl({ schemes: ["https"] }),
alt: string({ max: 1_000, default: "" }),
width: imageDimension("600"),
height: imageDimension("300"),
decorative: boolean(false),
}) as never,
{
emailHtml: ({ node }) =>
emailCell(
`<img src="${html(attrs(node).src)}" alt="${html(attrs(node).decorative ? "" : attrs(node).alt)}" width="${html(attrs(node).width)}" height="${html(attrs(node).height)}" style="border:0;display:block;height:auto;width:100%;max-width:${html(attrs(node).width)}px;" />`,
node,
),
ampEmail: ({ node }) =>
emailCell(
`<amp-img src="${html(attrs(node).src)}" alt="${html(attrs(node).decorative ? "" : attrs(node).alt)}" width="${html(attrs(node).width)}" height="${html(attrs(node).height)}" layout="responsive"></amp-img>`,
node,
),
mjml: ({ node }) =>
`<mj-image src="${html(attrs(node).src)}" alt="${html(attrs(node).decorative ? "" : attrs(node).alt)}" width="${html(attrs(node).width)}px" height="${html(attrs(node).height)}px" />`,
},
({ node }) => (attrs(node).decorative ? "" : `[${String(attrs(node).alt)}]\n\n`),
);
export const buttonBlock = leaf(
"content.button",
"Button",
"icon-[lucide--mouse-pointer-click]",
object({
label: string({ min: 1, max: 200, default: "Button" }),
href: safeUrl({ schemes: ["https", "mailto"] }),
}) as never,
{
emailHtml: ({ node, context }) =>
emailCell(
`<table role="presentation" align="${buttonAlignment(node, context)}" cellpadding="0" cellspacing="0" border="0"><tr><td bgcolor="${html(buttonBackground(node, context))}" style="border-radius:${html(buttonVisuals(node).radius)};"><a href="${html(attrs(node).href)}" style="${html(buttonVisualCss(node))};display:inline-block;font-family:inherit;padding:12px 20px;text-decoration:none;">${html(attrs(node).label)}</a></td></tr></table>`,
node,
),
ampEmail: ({ node, context }) =>
emailCell(
`<a class="button ${ampLayoutClass(node)}" href="${html(attrs(node).href)}">${html(attrs(node).label)}</a>`,
node,
buttonAlignment(node, context),
),
mjml: ({ node, context }) =>
`<mj-button align="${buttonAlignment(node, context)}" background-color="${html(buttonBackground(node, context))}" color="${html(buttonVisuals(node).color)}" font-size="${html(buttonVisuals(node).fontSize)}" font-weight="${html(buttonVisuals(node).fontWeight)}" border-radius="${html(buttonVisuals(node).radius)}" href="${html(attrs(node).href)}">${html(attrs(node).label)}</mj-button>`,
},
({ node }) => `${String(attrs(node).label)}: ${String(attrs(node).href)}\n\n`,
);
export const dividerBlock = leaf(
"content.divider",
"Divider",
"icon-[lucide--minus]",
empty,
{
emailHtml: ({ node }) =>
emailCell(`<hr style="border:0;border-top:1px solid #d1d5db;margin:16px 0;" />`, node),
ampEmail: ({ node }) => emailCell(`<hr />`, node),
mjml: () => `<mj-divider />`,
},
() => "----------------\n",
);
export const spacerBlock = leaf(
"content.spacer",
"Spacer",
"icon-[lucide--move-vertical]",
object({ height: imageDimension("24", 0) }) as never,
{
emailHtml: ({ node }) =>
emailCell(
`<div style="font-size:1px;height:${html(attrs(node).height)}px;line-height:${html(attrs(node).height)}px;">&nbsp;</div>`,
node,
),
ampEmail: ({ node }) => emailCell(`<div class="${ampLayoutClass(node)}">&nbsp;</div>`, node),
mjml: ({ node }) => `<mj-spacer height="${html(attrs(node).height)}px" />`,
},
() => "\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: imageDimension("600"),
height: imageDimension("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;width:100%;max-width:${html(attrs(node).width)}px;" /></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:inherit;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`,
);
const countdownUrl = (node: BlockNode, context: unknown): string | undefined => {
const timestamp = Date.parse(String(attrs(node).deadline));
if (!Number.isFinite(timestamp)) throw new Error("Countdown deadline must be a valid date.");
const endpoint = (context as { countdownImageUrl?: string })?.countdownImageUrl;
if (!endpoint) return undefined;
const url = new URL(endpoint);
url.searchParams.set("deadline", String(timestamp));
return html(url.href);
};
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, context }) => {
const url = countdownUrl(node, context);
return emailCell(`<p style="font-family:inherit;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>${url ? `<img src="${url}" width="320" height="64" alt="${html(attrs(node).label)} — ${html(attrs(node).deadline)}" style="display:block;width:320px;max-width:100%;height:auto;" />` : ""}`, node);
},
ampEmail: ({ node, context }) => {
const url = countdownUrl(node, context);
return emailCell(`<p>${html(attrs(node).label)}: <time datetime="${html(attrs(node).deadline)}">${html(attrs(node).deadline)}</time></p>${url ? `<amp-img src="${url}" width="320" height="64" layout="responsive" alt="${html(attrs(node).label)}"></amp-img>` : ""}`, node);
},
mjml: ({ node, context }) => {
const url = countdownUrl(node, context);
return `<mj-text font-size="20px" font-weight="700">${html(attrs(node).label)}: ${html(attrs(node).deadline)}</mj-text>${url ? `<mj-image src="${url}" width="320px" alt="${html(attrs(node).label)}" />` : ""}`;
},
},
({ 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:inherit;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({ 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(`${marketingImage(node, "html")}<h2 style="font-family:inherit;"><a href="${html(attrs(node).href)}">${html(attrs(node).title)}</a></h2><p style="font-family:inherit;">${html(attrs(node).summary)}</p>`, node),
ampEmail: ({ node }) => emailCell(`${marketingImage(node, "amp")}<h2><a href="${html(attrs(node).href)}">${html(attrs(node).title)}</a></h2><p>${html(attrs(node).summary)}</p>`, node),
mjml: ({ node }) => `${marketingImage(node, "mjml")}<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({ 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(`${marketingImage(node, "html")}<h2 style="font-family:inherit;">${html(attrs(node).title)}</h2><p style="font-family:inherit;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(`${marketingImage(node, "amp")}<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 }) => `${marketingImage(node, "mjml")}<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[] = [
emailDocumentBlock,
sectionBlock,
columnsBlock,
columnBlock,
headingBlock,
textBlock,
imageBlock,
buttonBlock,
dividerBlock,
spacerBlock,
videoBlock,
socialBlock,
countdownBlock,
surveyBlock,
feedItemBlock,
productBlock,
];