import { createRequire } from "node:module"; import { DEFAULT_LIMITS, registryForKind, validateDocument, type BlockDocument, type BlockNode, type Diagnostic, type Result, } from "@wrnexus/blocks"; import { emailKind } from "./email.ts"; interface MjmlAst { tagName: string; attributes?: Record; 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, ) => MjmlAst; const compileMjml = require("mjml") as ( value: string, options?: Record, ) => 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 { 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 (/ DEFAULT_LIMITS.maxNodes || depth > DEFAULT_LIMITS.maxDepth) { return { ok: false, errors: [{ code: "mjml.limits", message: "MJML exceeds document node or depth limits.", severity: "error" }] }; } for (const child of node.children ?? []) pending.push({ node: child, depth: depth + 1 }); } const decode = (value: string) => value.replace(/&(?:#(x[0-9a-f]+|[0-9]+)|(amp|lt|gt|quot|apos));/gi, (original, numeric: string | undefined, name: string | undefined) => { if (numeric) { const point = numeric[0]!.toLowerCase() === "x" ? Number.parseInt(numeric.slice(1), 16) : Number(numeric); return point > 0 && point <= 0x10ffff && !(point >= 0xd800 && point <= 0xdfff) ? String.fromCodePoint(point) : original; } return ({ amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" } as Record)[name!.toLowerCase()]!; }); const brand: Record = {}; let importedSubject = "", importedPreheader = ""; const attributeError = (node: MjmlAst, attribute?: string) => errors.push({ code: "mjml.header", message: `Unsupported MJML header setting ${node.tagName}${attribute ? `.${attribute}` : ""}.`, severity: "error", element: node.tagName, attribute }); const onlyAttributes = (node: MjmlAst, allowed: string[]) => { for (const key of Object.keys(node.attributes ?? {})) if (!allowed.includes(key)) attributeError(node, key); }; for (const head of (ast.children ?? []).filter(node => node.tagName === "mj-head")) { onlyAttributes(head, []); for (const node of head.children ?? []) { if (node.tagName === "mj-title" || node.tagName === "mj-preview") { onlyAttributes(node, []); if (node.tagName === "mj-title") importedSubject = decode(node.content ?? ""); else importedPreheader = decode(node.content ?? ""); } else if (node.tagName === "mj-attributes") { onlyAttributes(node, []); for (const defaults of node.children ?? []) { if (defaults.tagName === "mj-all") { onlyAttributes(defaults, ["font-family"]); if (defaults.attributes?.["font-family"]) brand.fontFamily = decode(defaults.attributes["font-family"]); } else if (defaults.tagName === "mj-text") { onlyAttributes(defaults, ["color"]); if (defaults.attributes?.color) brand.textColor = decode(defaults.attributes.color); } else if (defaults.tagName === "mj-button") { onlyAttributes(defaults, ["background-color"]); if (defaults.attributes?.["background-color"]) brand.linkColor = decode(defaults.attributes["background-color"]); } else attributeError(defaults); } } else if (node.tagName === "mj-breakpoint") { onlyAttributes(node, ["width"]); if (node.attributes?.width !== "480px") attributeError(node, "width"); } else attributeError(node); } } const bodyAttributes = (ast.children ?? []).find(node => node.tagName === "mj-body")?.attributes ?? {}; const wrapColumns = (nodes: BlockNode[]): BlockNode[] => { const output: BlockNode[] = []; for (let index = 0; index < nodes.length;) { if (nodes[index]!.type !== "layout.column") { output.push(nodes[index++]!); continue; } const columns: BlockNode[] = []; while (index < nodes.length && nodes[index]!.type === "layout.column") columns.push(nodes[index++]!); let explicit = 0; const missing = columns.filter(column => !column.props.width); for (const column of columns) { const width = String(column.props.width || ""); if (!width) continue; if (!/^\d+(?:\.\d+)?%$/.test(width) || Number.parseFloat(width) <= 0 || Number.parseFloat(width) > 100) { errors.push({ code: "mjml.column-width", message: "Column width must be a percentage between 0 and 100.", severity: "error", element: "mj-column", attribute: "width" }); } else explicit += Number.parseFloat(width); } if (explicit > 100 || (!missing.length && Math.abs(explicit - 100) > 0.001) || (missing.length && explicit >= 100)) { errors.push({ code: "mjml.column-width", message: "Column widths must fill one 100% row.", severity: "error", element: "mj-column", attribute: "width" }); } let assigned = 0; missing.forEach((column, position) => { const width = position === missing.length - 1 ? 100 - explicit - assigned : Math.round((100 - explicit) / missing.length * 100) / 100; column.props.width = `${width}%`; assigned += width; }); output.push({ id: options.id(), type: "layout.columns", version: 1, props: {}, styles: {}, slots: { columns } }); } return output; }; const convert = (node: MjmlAst): BlockNode[] => { if (node.tagName === "mj-head") return []; if (!supported.has(node.tagName)) { errors.push({ code: "mjml.component", message: `Unsupported MJML component ${node.tagName}.`, severity: "error", element: node.tagName, }); return []; } const converted = (node.children ?? []).flatMap(convert); const children = containers.has(node.tagName) && node.tagName !== "mj-column" ? wrapColumns(converted) : converted; 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 ?? "" }, 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: decode(a.src ?? ""), alt: decode(a.alt ?? ""), width: (a.width ?? "600").replace(/px$/, ""), height: (a.height ?? "300").replace(/px$/, ""), decorative: false, }, styles: {}, slots: {}, }, ]; if (node.tagName === "mj-button") { const buttonStyles: BlockNode["styles"] = {}; const mappings: Record = { align: ["textAlign", /^(left|center|right|start|end)$/], "background-color": ["backgroundColor", /^(#[0-9a-f]{3,8}|rgba?\([0-9., %]+\))$/i], color: ["color", /^(#[0-9a-f]{3,8}|rgba?\([0-9., %]+\))$/i], "font-size": ["fontSize", /^\d+(?:\.\d+)?(?:px|rem|em|%)$/], "font-weight": ["fontWeight", /^(normal|bold|[1-9]00)$/], "border-radius": ["borderRadius", /^\d+(?:\.\d+)?(?:px|rem|em|%)$/], "inner-padding": ["padding", /^\d+(?:\.\d+)?(?:px|rem|em|%)(?:\s+\d+(?:\.\d+)?(?:px|rem|em|%)){0,3}$/], }; for (const [attribute, raw] of Object.entries(a)) { if (attribute === "href") continue; const mapping = mappings[attribute]; const value = decode(raw).trim(); if (!mapping || value.length > 80 || !mapping[1].test(value)) { errors.push({ code: "mjml.button-attribute", message: `Unsupported button attribute or value: ${attribute}.`, severity: "error", element: node.tagName, attribute }); } else buttonStyles[mapping[0]] = { base: value }; } return [ { id, type: "content.button", version: 1, props: { label: decode(node.content ?? "Button"), href: decode(a.href ?? "") }, styles: buttonStyles, 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: (bodyAttributes.width ?? "600").replace(/px$/, ""), background: bodyAttributes["background-color"] ?? "#f3f4f6", ...brand }, styles: {}, slots: { content: content.map((node) => node.type === "layout.column" ? { id: options.id(), type: "layout.columns", version: 1, props: {}, styles: {}, slots: { columns: [node] }, } : node, ), }, }; const document: BlockDocument = { schemaVersion: 1, kind: "email", meta: { subject: options.subject ?? (importedSubject || "Imported email"), preheader: options.preheader ?? importedPreheader, lang: "en" }, theme: { tokens: {} }, root, }; const validated = validateDocument(document, emailKind, registryForKind(emailKind).freeze()); if (!validated.ok) return { ok: false, errors: validated.errors }; return { ok: true, value: document, }; } export async function validateMjml(source: string): Promise { 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", }, ]; } }