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; 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 (/ { 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 { 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", }, ]; } }