feat(builder): add dynamic blocks email and dnd packages
This commit is contained in:
1 parent
06433e4ac4
commit
0bd2a6860d
37 files changed
+3060
-1
No files matched your search
@@ -0,0 +1,5 @@
|
||||
# @wrnexus/email-renderer
|
||||
|
||||
Email target pack for `@wrnexus/blocks`. Standard HTML and plain text are
|
||||
required outputs; valid AMP Email is an optional MIME alternative. MJML is a
|
||||
strict import/export interoperability format, never the canonical document.
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "@wrnexus/email-renderer",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "WRNexus block email renderers with HTML, text, AMP Email, and MJML interoperability.",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
"README.md"
|
||||
],
|
||||
"scripts": {
|
||||
"test": "bun test",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/blocks": "workspace:*",
|
||||
"amphtml-validator": "1.0.38",
|
||||
"mjml": "5.4.0",
|
||||
"mjml-parser-xml": "5.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.4.0",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
import {
|
||||
boolean,
|
||||
choice,
|
||||
defineBlock,
|
||||
json,
|
||||
object,
|
||||
record,
|
||||
safeUrl,
|
||||
string,
|
||||
type BlockDefinition,
|
||||
type BlockNode,
|
||||
} from "@wrnexus/blocks";
|
||||
|
||||
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 emailCell = (body: string, node: BlockNode) =>
|
||||
`<tr><td data-block-id="${html(node.id)}" style="padding:0;">${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: string({ default: "600" }),
|
||||
background: string({ default: "#f3f4f6" }),
|
||||
}) as never,
|
||||
styles,
|
||||
slots: { content: contentSlot },
|
||||
},
|
||||
create: ({ id }) => ({
|
||||
id: id(),
|
||||
type: "layout.email-document",
|
||||
version: 1,
|
||||
props: { width: "600", background: "#f3f4f6" },
|
||||
styles: {},
|
||||
slots: { content: [] },
|
||||
}),
|
||||
renderers: {
|
||||
emailHtml: ({ node, slots }) =>
|
||||
`<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;background:${html(attrs(node).background)};"><tr><td align="center"><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;">${slots.content}</table></td></tr></table>`,
|
||||
ampEmail: ({ node, slots }) =>
|
||||
`<table role="presentation" width="100%" class="email-bg"><tr><td align="center"><table role="presentation" width="${html(attrs(node).width)}" class="email-body">${slots.content}</table></td></tr></table>`,
|
||||
mjml: ({ node, slots }) =>
|
||||
`<mjml><mj-body width="${html(attrs(node).width)}px" background-color="${html(attrs(node).background)}">${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%"><tr><td style="background:${html(attrs(node).background)};padding:${html(attrs(node).padding)};">${slots.content}</td></tr></table>`,
|
||||
node,
|
||||
),
|
||||
ampEmail: ({ node, slots }) =>
|
||||
emailCell(
|
||||
`<table role="presentation" width="100%"><tr><td class="section">${slots.content}</td></tr></table>`,
|
||||
node,
|
||||
),
|
||||
mjml: ({ node, slots }) =>
|
||||
`<mj-section background-color="${html(attrs(node).background)}" padding="${html(attrs(node).padding)}"><mj-column>${slots.content}</mj-column></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>${slots.columns}</tr></table>`, node),
|
||||
mjml: ({ slots }) => 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 class="wrn-column" width="${html(attrs(node).width)}" valign="top">${slots.content}</td>`,
|
||||
ampEmail: ({ node, slots }) =>
|
||||
`<td width="${html(attrs(node).width)}" valign="top">${slots.content}</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,
|
||||
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:Arial,sans-serif;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:Arial,sans-serif;line-height:1.5;margin:0 0 16px;white-space:pre-line;">${html(attrs(node).text)}</p>`,
|
||||
node,
|
||||
),
|
||||
ampEmail: ({ node }) => emailCell(`<p>${html(attrs(node).text)}</p>`, node),
|
||||
mjml: ({ node }) => `<mj-text>${html(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: string({ default: "600" }),
|
||||
height: string({ default: "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;max-width:100%;" />`,
|
||||
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).alt)}" width="${html(attrs(node).width)}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 }) =>
|
||||
emailCell(
|
||||
`<table role="presentation"><tr><td bgcolor="#2563eb" style="border-radius:6px;"><a href="${html(attrs(node).href)}" style="color:#ffffff;display:inline-block;font-family:Arial,sans-serif;font-weight:700;padding:12px 20px;text-decoration:none;">${html(attrs(node).label)}</a></td></tr></table>`,
|
||||
node,
|
||||
),
|
||||
ampEmail: ({ node }) =>
|
||||
emailCell(
|
||||
`<a class="button" href="${html(attrs(node).href)}">${html(attrs(node).label)}</a>`,
|
||||
node,
|
||||
),
|
||||
mjml: ({ node }) =>
|
||||
`<mj-button 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: string({ default: "24" }) }) 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="spacer"> </div>`, node),
|
||||
mjml: ({ node }) => `<mj-spacer height="${html(attrs(node).height)}px" />`,
|
||||
},
|
||||
() => "\n",
|
||||
);
|
||||
|
||||
export const emailCoreBlocks: readonly BlockDefinition[] = [
|
||||
emailDocumentBlock,
|
||||
sectionBlock,
|
||||
columnsBlock,
|
||||
columnBlock,
|
||||
headingBlock,
|
||||
textBlock,
|
||||
imageBlock,
|
||||
buttonBlock,
|
||||
dividerBlock,
|
||||
spacerBlock,
|
||||
];
|
||||
@@ -0,0 +1,149 @@
|
||||
import { createRequire } from "node:module";
|
||||
import {
|
||||
DEFAULT_LIMITS,
|
||||
defineDocumentKind,
|
||||
escapeHtml,
|
||||
object,
|
||||
registryForKind,
|
||||
renderTree,
|
||||
string,
|
||||
type BlockDocument,
|
||||
type Diagnostic,
|
||||
type Result,
|
||||
} from "@wrnexus/blocks";
|
||||
import { emailCoreBlocks } from "./blocks.ts";
|
||||
|
||||
interface AmpValidationError {
|
||||
severity: string;
|
||||
line: number;
|
||||
col: number;
|
||||
message: string;
|
||||
}
|
||||
const amphtmlValidator = createRequire(import.meta.url)("amphtml-validator") as {
|
||||
getInstance(): Promise<{
|
||||
validateString(
|
||||
value: string,
|
||||
format: string,
|
||||
): {
|
||||
status: string;
|
||||
errors: AmpValidationError[];
|
||||
};
|
||||
}>;
|
||||
};
|
||||
|
||||
export interface ValidationReport {
|
||||
valid: boolean;
|
||||
diagnostics: Diagnostic[];
|
||||
}
|
||||
export interface EmailRenderBundle {
|
||||
kind: "email";
|
||||
subject: string;
|
||||
preheader?: string;
|
||||
parts: {
|
||||
text: { contentType: "text/plain; charset=utf-8"; content: string };
|
||||
amp?: {
|
||||
contentType: "text/x-amp-html; charset=utf-8";
|
||||
content: string;
|
||||
validation: ValidationReport;
|
||||
};
|
||||
html: { contentType: "text/html; charset=utf-8"; content: string };
|
||||
};
|
||||
exports?: { mjml?: string };
|
||||
warnings: Diagnostic[];
|
||||
assets: string[];
|
||||
}
|
||||
|
||||
export const emailKind = defineDocumentKind({
|
||||
kind: "email",
|
||||
version: 1,
|
||||
metadata: object({
|
||||
subject: string({ min: 1, max: 998 }),
|
||||
preheader: string({ max: 500, default: "" }),
|
||||
lang: string({ max: 35, default: "en" }),
|
||||
}) as never,
|
||||
rootTypes: ["layout.email-document"],
|
||||
blocks: emailCoreBlocks,
|
||||
variables: { recipientName: "string", unsubscribeUrl: "string", organizationAddress: "string" },
|
||||
breakpoints: { compact: 620 },
|
||||
capabilities: ["email-html", "plain-text", "amp-email", "mjml"],
|
||||
targets: ["emailHtml", "plainText", "ampEmail", "mjml"],
|
||||
limits: { ...DEFAULT_LIMITS, maxOutputBytes: 2_000_000 },
|
||||
urlSchemes: ["https", "mailto"],
|
||||
});
|
||||
|
||||
const head = (document: BlockDocument, body: string, direction: "ltr" | "rtl") =>
|
||||
`<!doctype html><html lang="${escapeHtml(document.meta.lang)}" dir="${direction}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="x-apple-disable-message-reformatting"><meta name="color-scheme" content="light dark"><title>${escapeHtml(document.meta.subject)}</title><style>@media only screen and (max-width:620px){.wrn-email{width:100%!important}.wrn-columns{display:block!important}.wrn-column{display:block!important;width:100%!important}}</style></head><body style="margin:0;padding:0;">${document.meta.preheader ? `<div style="display:none;max-height:0;overflow:hidden;opacity:0;">${escapeHtml(document.meta.preheader)}</div>` : ""}${body}</body></html>`;
|
||||
const ampHead = (document: BlockDocument, body: string, direction: "ltr" | "rtl") =>
|
||||
`<!doctype html><html amp4email data-css-strict lang="${escapeHtml(document.meta.lang)}" dir="${direction}"><head><meta charset="utf-8"><script async src="https://cdn.ampproject.org/v0.js"></script><style amp4email-boilerplate>body{visibility:hidden}</style><style amp-custom>.email-body{max-width:600px;width:100%}.section{padding:24px}.button{background:#2563eb;border-radius:6px;color:#fff;display:inline-block;font-weight:700;padding:12px 20px;text-decoration:none}.spacer{height:24px}</style></head><body>${body}</body></html>`;
|
||||
|
||||
export async function validateAmpEmail(content: string): Promise<ValidationReport> {
|
||||
const validator = await amphtmlValidator.getInstance();
|
||||
const result = validator.validateString(content, "AMP4EMAIL");
|
||||
return {
|
||||
valid: result.status === "PASS",
|
||||
diagnostics: result.errors.map((error) => ({
|
||||
code: "amp.validation",
|
||||
message: error.message,
|
||||
severity: error.severity === "ERROR" ? "error" : "warning",
|
||||
path: `${error.line}:${error.col}`,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function renderEmail(
|
||||
document: BlockDocument,
|
||||
options: {
|
||||
amp?: boolean;
|
||||
mjml?: boolean;
|
||||
locale?: string;
|
||||
timezone?: string;
|
||||
direction?: "ltr" | "rtl";
|
||||
} = {},
|
||||
): Promise<Result<EmailRenderBundle>> {
|
||||
const registry = registryForKind(emailKind).freeze();
|
||||
const base = {
|
||||
locale: options.locale ?? "en",
|
||||
timezone: options.timezone ?? "UTC",
|
||||
direction: options.direction ?? "ltr",
|
||||
};
|
||||
const [htmlResult, textResult, mjmlResult] = await Promise.all([
|
||||
renderTree(document, emailKind, registry, { ...base, target: "emailHtml" }),
|
||||
renderTree(document, emailKind, registry, { ...base, target: "plainText" }),
|
||||
options.mjml
|
||||
? renderTree(document, emailKind, registry, { ...base, target: "mjml" })
|
||||
: Promise.resolve(undefined),
|
||||
]);
|
||||
if (!htmlResult.ok) return htmlResult;
|
||||
if (!textResult.ok) return textResult;
|
||||
const warnings: Diagnostic[] = [...htmlResult.value.warnings, ...textResult.value.warnings];
|
||||
const bundle: EmailRenderBundle = {
|
||||
kind: "email",
|
||||
subject: String(document.meta.subject),
|
||||
preheader: String(document.meta.preheader || "") || undefined,
|
||||
parts: {
|
||||
text: {
|
||||
contentType: "text/plain; charset=utf-8",
|
||||
content: textResult.value.content.trimEnd() + "\n",
|
||||
},
|
||||
html: {
|
||||
contentType: "text/html; charset=utf-8",
|
||||
content: head(document, htmlResult.value.content, base.direction),
|
||||
},
|
||||
},
|
||||
warnings,
|
||||
assets: [],
|
||||
};
|
||||
if (mjmlResult?.ok) bundle.exports = { mjml: mjmlResult.value.content };
|
||||
if (options.amp) {
|
||||
const amp = await renderTree(document, emailKind, registry, { ...base, target: "ampEmail" });
|
||||
if (amp.ok) {
|
||||
const content = ampHead(document, amp.value.content, base.direction);
|
||||
const validation = await validateAmpEmail(content);
|
||||
if (validation.valid)
|
||||
bundle.parts.amp = { contentType: "text/x-amp-html; charset=utf-8", content, validation };
|
||||
else warnings.push(...validation.diagnostics);
|
||||
} else
|
||||
warnings.push(...amp.errors.map((error) => ({ ...error, severity: "warning" as const })));
|
||||
}
|
||||
return { ok: true, value: bundle };
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./blocks.ts";
|
||||
export * from "./email.ts";
|
||||
export * from "./mjml.ts";
|
||||
@@ -0,0 +1,278 @@
|
||||
import { createRequire } from "node:module";
|
||||
import {
|
||||
DEFAULT_LIMITS,
|
||||
type BlockDocument,
|
||||
type BlockNode,
|
||||
type Diagnostic,
|
||||
type Result,
|
||||
} from "@wrnexus/blocks";
|
||||
|
||||
interface MjmlAst {
|
||||
tagName: string;
|
||||
attributes?: Record<string, string>;
|
||||
children?: MjmlAst[];
|
||||
content?: string;
|
||||
}
|
||||
interface MjmlCompileError {
|
||||
line?: number;
|
||||
message: string;
|
||||
tagName?: string;
|
||||
}
|
||||
const require = createRequire(import.meta.url);
|
||||
const parseMjml = require("mjml-parser-xml") as (
|
||||
value: string,
|
||||
options?: Record<string, unknown>,
|
||||
) => MjmlAst;
|
||||
const compileMjml = require("mjml") as (
|
||||
value: string,
|
||||
options?: Record<string, unknown>,
|
||||
) => Promise<{ html: string; errors: MjmlCompileError[] }>;
|
||||
|
||||
const supported = new Set([
|
||||
"mjml",
|
||||
"mj-head",
|
||||
"mj-title",
|
||||
"mj-preview",
|
||||
"mj-attributes",
|
||||
"mj-breakpoint",
|
||||
"mj-body",
|
||||
"mj-wrapper",
|
||||
"mj-section",
|
||||
"mj-group",
|
||||
"mj-column",
|
||||
"mj-text",
|
||||
"mj-image",
|
||||
"mj-button",
|
||||
"mj-divider",
|
||||
"mj-spacer",
|
||||
]);
|
||||
const containers = new Set([
|
||||
"mjml",
|
||||
"mj-body",
|
||||
"mj-wrapper",
|
||||
"mj-section",
|
||||
"mj-group",
|
||||
"mj-column",
|
||||
]);
|
||||
|
||||
export interface MjmlDiagnostic extends Diagnostic {
|
||||
line?: number;
|
||||
column?: number;
|
||||
element?: string;
|
||||
attribute?: string;
|
||||
}
|
||||
|
||||
export function importMjml(
|
||||
source: string,
|
||||
options: { id(): string; subject: string; preheader?: string },
|
||||
): Result<BlockDocument, MjmlDiagnostic[]> {
|
||||
if (new TextEncoder().encode(source).byteLength > DEFAULT_LIMITS.maxBytes)
|
||||
return {
|
||||
ok: false,
|
||||
errors: [{ code: "mjml.size", message: "MJML source is too large.", severity: "error" }],
|
||||
};
|
||||
if (/<mj-(?:raw|include)\b|<script\b|\{[%{[]/i.test(source))
|
||||
return {
|
||||
ok: false,
|
||||
errors: [
|
||||
{
|
||||
code: "mjml.unsafe",
|
||||
message: "MJML raw/include, scripts, and template syntax are not supported.",
|
||||
severity: "error",
|
||||
},
|
||||
],
|
||||
};
|
||||
let ast: MjmlAst;
|
||||
try {
|
||||
ast = parseMjml(source, { ignoreIncludes: true, validationLevel: "strict" });
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
errors: [
|
||||
{
|
||||
code: "mjml.parse",
|
||||
message: error instanceof Error ? error.message : "Invalid MJML.",
|
||||
severity: "error",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
const errors: MjmlDiagnostic[] = [];
|
||||
const convert = (node: MjmlAst): BlockNode[] => {
|
||||
if (!supported.has(node.tagName)) {
|
||||
errors.push({
|
||||
code: "mjml.component",
|
||||
message: `Unsupported MJML component ${node.tagName}.`,
|
||||
severity: "error",
|
||||
element: node.tagName,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
const children = (node.children ?? []).flatMap(convert);
|
||||
if (node.tagName === "mjml" || node.tagName === "mj-head" || node.tagName === "mj-body")
|
||||
return children;
|
||||
if (
|
||||
node.tagName === "mj-title" ||
|
||||
node.tagName === "mj-preview" ||
|
||||
node.tagName === "mj-attributes" ||
|
||||
node.tagName === "mj-breakpoint"
|
||||
)
|
||||
return [];
|
||||
const id = options.id();
|
||||
const a = node.attributes ?? {};
|
||||
if (["mj-wrapper", "mj-section", "mj-group"].includes(node.tagName))
|
||||
return [
|
||||
{
|
||||
id,
|
||||
type: "layout.section",
|
||||
version: 1,
|
||||
props: { background: a["background-color"] ?? "#ffffff", padding: a.padding ?? "24px" },
|
||||
styles: {},
|
||||
slots: { content: children },
|
||||
},
|
||||
];
|
||||
if (node.tagName === "mj-column")
|
||||
return [
|
||||
{
|
||||
id,
|
||||
type: "layout.column",
|
||||
version: 1,
|
||||
props: { width: a.width ?? "100%" },
|
||||
styles: {},
|
||||
slots: { content: children },
|
||||
},
|
||||
];
|
||||
if (node.tagName === "mj-text")
|
||||
return [
|
||||
{
|
||||
id,
|
||||
type: "content.text",
|
||||
version: 1,
|
||||
props: { text: node.content ?? "" },
|
||||
styles: {},
|
||||
slots: {},
|
||||
},
|
||||
];
|
||||
if (node.tagName === "mj-image")
|
||||
return [
|
||||
{
|
||||
id,
|
||||
type: "content.image",
|
||||
version: 1,
|
||||
props: {
|
||||
src: a.src ?? "",
|
||||
alt: a.alt ?? "",
|
||||
width: (a.width ?? "600").replace(/px$/, ""),
|
||||
height: (a.height ?? "300").replace(/px$/, ""),
|
||||
decorative: false,
|
||||
},
|
||||
styles: {},
|
||||
slots: {},
|
||||
},
|
||||
];
|
||||
if (node.tagName === "mj-button")
|
||||
return [
|
||||
{
|
||||
id,
|
||||
type: "content.button",
|
||||
version: 1,
|
||||
props: { label: node.content ?? "Button", href: a.href ?? "" },
|
||||
styles: {},
|
||||
slots: {},
|
||||
},
|
||||
];
|
||||
if (node.tagName === "mj-divider")
|
||||
return [{ id, type: "content.divider", version: 1, props: {}, styles: {}, slots: {} }];
|
||||
if (node.tagName === "mj-spacer")
|
||||
return [
|
||||
{
|
||||
id,
|
||||
type: "content.spacer",
|
||||
version: 1,
|
||||
props: { height: (a.height ?? "24").replace(/px$/, "") },
|
||||
styles: {},
|
||||
slots: {},
|
||||
},
|
||||
];
|
||||
return containers.has(node.tagName) ? children : [];
|
||||
};
|
||||
const content = convert(ast);
|
||||
if (errors.length) return { ok: false, errors };
|
||||
const root: BlockNode = {
|
||||
id: options.id(),
|
||||
type: "layout.email-document",
|
||||
version: 1,
|
||||
props: { width: "600", background: "#f3f4f6" },
|
||||
styles: {},
|
||||
slots: {
|
||||
content: content.map((node) =>
|
||||
node.type === "layout.column"
|
||||
? {
|
||||
id: options.id(),
|
||||
type: "layout.columns",
|
||||
version: 1,
|
||||
props: {},
|
||||
styles: {},
|
||||
slots: { columns: [node] },
|
||||
}
|
||||
: node,
|
||||
),
|
||||
},
|
||||
};
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
schemaVersion: 1,
|
||||
kind: "email",
|
||||
meta: { subject: options.subject, preheader: options.preheader ?? "", lang: "en" },
|
||||
theme: { tokens: {} },
|
||||
root,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function validateMjml(source: string): Promise<MjmlDiagnostic[]> {
|
||||
try {
|
||||
const result = await compileMjml(source, {
|
||||
validationLevel: "strict",
|
||||
ignoreIncludes: true,
|
||||
minify: false,
|
||||
});
|
||||
return (result.errors ?? []).map((error) => ({
|
||||
code: "mjml.validation",
|
||||
message: error.message,
|
||||
severity: "error",
|
||||
line: error.line,
|
||||
element: error.tagName,
|
||||
}));
|
||||
} catch (error) {
|
||||
const details =
|
||||
error && typeof error === "object" && "errors" in error
|
||||
? ((
|
||||
error as {
|
||||
errors?: Array<{
|
||||
line?: number;
|
||||
message?: string;
|
||||
formattedMessage?: string;
|
||||
tagName?: string;
|
||||
}>;
|
||||
}
|
||||
).errors ?? [])
|
||||
: [];
|
||||
if (details.length)
|
||||
return details.map((detail) => ({
|
||||
code: "mjml.validation",
|
||||
message: detail.message ?? detail.formattedMessage ?? "Invalid MJML.",
|
||||
severity: "error",
|
||||
line: detail.line,
|
||||
element: detail.tagName,
|
||||
}));
|
||||
return [
|
||||
{
|
||||
code: "mjml.validation",
|
||||
message: error instanceof Error ? error.message : "Invalid MJML.",
|
||||
severity: "error",
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
renderEmail,
|
||||
validateAmpEmail,
|
||||
validateMjml,
|
||||
importMjml,
|
||||
type EmailRenderBundle,
|
||||
} from "../src/index.ts";
|
||||
import type { BlockDocument } from "@wrnexus/blocks";
|
||||
|
||||
const document: BlockDocument = {
|
||||
schemaVersion: 1,
|
||||
kind: "email",
|
||||
meta: { subject: "Welcome", preheader: "Preview", lang: "en" },
|
||||
theme: { tokens: {} },
|
||||
root: {
|
||||
id: "root",
|
||||
type: "layout.email-document",
|
||||
version: 1,
|
||||
props: { width: "600", background: "#f3f4f6" },
|
||||
styles: {},
|
||||
slots: {
|
||||
content: [
|
||||
{
|
||||
id: "section",
|
||||
type: "layout.section",
|
||||
version: 1,
|
||||
props: { background: "#ffffff", padding: "24px" },
|
||||
styles: {},
|
||||
slots: {
|
||||
content: [
|
||||
{
|
||||
id: "heading",
|
||||
type: "content.heading",
|
||||
version: 1,
|
||||
props: { text: "Hello <world>", level: "1" },
|
||||
styles: {},
|
||||
slots: {},
|
||||
},
|
||||
{
|
||||
id: "button",
|
||||
type: "content.button",
|
||||
version: 1,
|
||||
props: { label: "Open", href: "https://example.com/" },
|
||||
styles: {},
|
||||
slots: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe("email renderer", () => {
|
||||
test("renders deterministic HTML, text, AMP, and MJML", async () => {
|
||||
const first = await renderEmail(document, { amp: true, mjml: true });
|
||||
const second = await renderEmail(document, { amp: true, mjml: true });
|
||||
expect(first).toEqual(second);
|
||||
expect(first.ok).toBe(true);
|
||||
if (!first.ok) return;
|
||||
const value: EmailRenderBundle = first.value;
|
||||
expect(value.parts.html.content).toContain("Hello <world>");
|
||||
expect(value.parts.text.content).toContain("Hello <world>");
|
||||
expect(value.parts.amp?.validation.valid).toBe(true);
|
||||
expect(value.exports?.mjml).toContain("<mjml>");
|
||||
});
|
||||
|
||||
test("validates AMP output", async () => {
|
||||
expect((await validateAmpEmail("<html></html>")).valid).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects unsafe MJML and imports supported content", () => {
|
||||
expect(
|
||||
importMjml("<mjml><mj-body><mj-raw>x</mj-raw></mj-body></mjml>", {
|
||||
id: () => crypto.randomUUID(),
|
||||
subject: "x",
|
||||
}).ok,
|
||||
).toBe(false);
|
||||
let n = 0;
|
||||
const imported = importMjml(
|
||||
"<mjml><mj-body><mj-section><mj-column><mj-text>Hello</mj-text></mj-column></mj-section></mj-body></mjml>",
|
||||
{ id: () => `n${++n}`, subject: "Imported" },
|
||||
);
|
||||
expect(imported.ok).toBe(true);
|
||||
});
|
||||
|
||||
test("strict MJML validation catches invalid nesting", async () => {
|
||||
expect(
|
||||
(await validateMjml("<mjml><mj-body><mj-text>x</mj-text></mj-body></mjml>")).length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"include": ["src/**/*.ts", "test/**/*.ts"]
|
||||
}
|
||||
Reference in new issue
Block a user