Files
WRNexusJS/packages/email-renderer/src/email.ts
T
Clintchiz fd777605e1
Quality / quality (ubuntu-latest) (push) Failing after 10m1s
Quality / quality (windows-latest) (push) Canceled after 0s
fix(email-renderer): stack columns only at compact widths
2026-09-05 12:44:09 +05:30

173 lines
7.1 KiB
TypeScript

import { createRequire } from "node:module";
import {
DEFAULT_LIMITS,
defineDocumentKind,
escapeHtml,
object,
registryForKind,
resolveDocumentVariables,
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: 480 },
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") => {
const props = document.root.props as Record<string, unknown>;
const textColor = escapeHtml(props.textColor ?? "#111827");
const linkColor = escapeHtml(props.linkColor ?? "#2563eb");
const fontFamily = escapeHtml(props.fontFamily ?? "Arial, sans-serif");
return `<!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>a{color:${linkColor}}@media only screen and (max-width:480px){.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;color:${textColor};font-family:${fontFamily};">${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";
variables?: Record<string, unknown>;
requireVariables?: boolean;
countdownImageUrl?: string;
} = {},
): Promise<Result<EmailRenderBundle>> {
const personalized = resolveDocumentVariables(document, emailKind.variables, options.variables, {
requireValues: options.requireVariables,
});
if (!personalized.ok) return personalized;
if (options.countdownImageUrl) {
try {
const url = new URL(options.countdownImageUrl);
if (url.username || url.password || (url.protocol !== "https:" && !(url.protocol === "http:" && (url.hostname === "localhost" || url.hostname.endsWith(".localhost"))))) throw new Error();
} catch {
return { ok: false, errors: [{ code: "countdown.endpoint", message: "Countdown images require HTTPS (or a local development host).", severity: "error" }] };
}
}
const source = personalized.value;
const registry = registryForKind(emailKind).freeze();
const base = {
locale: options.locale ?? "en",
timezone: options.timezone ?? "UTC",
direction: options.direction ?? "ltr",
countdownImageUrl: options.countdownImageUrl,
};
const [htmlResult, textResult, mjmlResult] = await Promise.all([
renderTree(source, emailKind, registry, { ...base, target: "emailHtml", data: options.variables }),
renderTree(source, emailKind, registry, { ...base, target: "plainText", data: options.variables }),
options.mjml
? renderTree(source, emailKind, registry, { ...base, target: "mjml", data: options.variables })
: 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(source.meta.subject),
preheader: String(source.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(source, htmlResult.value.content, base.direction),
},
},
warnings,
assets: [],
};
if (mjmlResult?.ok) bundle.exports = { mjml: mjmlResult.value.content };
if (options.amp) {
const amp = await renderTree(source, emailKind, registry, { ...base, target: "ampEmail", data: options.variables });
if (amp.ok) {
const content = ampHead(source, 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 };
}