578 lines
29 KiB
TypeScript
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("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """)
|
|
.replaceAll("'", "'");
|
|
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;"> </div>`,
|
|
node,
|
|
),
|
|
ampEmail: ({ node }) => emailCell(`<div class="${ampLayoutClass(node)}"> </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,
|
|
];
|