diff --git a/packages/email-renderer/package.json b/packages/email-renderer/package.json index decea1c9..d5a1c873 100644 --- a/packages/email-renderer/package.json +++ b/packages/email-renderer/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/email-renderer", - "version": "0.1.30", + "version": "0.1.31", "type": "module", "description": "WRNexus block email renderers with HTML, text, AMP Email, and MJML interoperability.", "exports": { diff --git a/packages/email-renderer/src/mjml.ts b/packages/email-renderer/src/mjml.ts index abb95592..65359e04 100644 --- a/packages/email-renderer/src/mjml.ts +++ b/packages/email-renderer/src/mjml.ts @@ -1,11 +1,14 @@ 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; @@ -98,6 +101,43 @@ export function importMjml( }; } const errors: MjmlDiagnostic[] = []; + if (!ast || ast.tagName !== "mjml") return { ok: false, errors: [{ code: "mjml.root", message: "MJML requires an mjml root element.", severity: "error" }] }; + const pending = [{ node: ast, depth: 0 }]; + let nodeCount = 0; + while (pending.length) { + const { node, depth } = pending.pop()!; + if (++nodeCount > 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 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 (!supported.has(node.tagName)) { errors.push({ @@ -108,7 +148,8 @@ export function importMjml( }); return []; } - const children = (node.children ?? []).flatMap(convert); + 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 ( @@ -137,7 +178,7 @@ export function importMjml( id, type: "layout.column", version: 1, - props: { width: a.width ?? "100%" }, + props: { width: a.width ?? "" }, styles: {}, slots: { content: children }, }, @@ -219,15 +260,18 @@ export function importMjml( ), }, }; - return { - ok: true, - value: { + const document: BlockDocument = { schemaVersion: 1, kind: "email", meta: { subject: options.subject, preheader: options.preheader ?? "", 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, }; } diff --git a/packages/email-renderer/test/mjml-import-layout.test.ts b/packages/email-renderer/test/mjml-import-layout.test.ts new file mode 100644 index 00000000..028232ef --- /dev/null +++ b/packages/email-renderer/test/mjml-import-layout.test.ts @@ -0,0 +1,38 @@ +import { expect, test } from "bun:test"; +import { importMjml, renderEmail } from "../src/index.ts"; + +const parse = (columns: string) => { + let id = 0; + return importMjml(`${columns}`, { subject: "Imported", id: () => `n${++id}` }); +}; + +test("imported columns form one native row with preserved order and exact width totals", async () => { + for (const widths of [["", ""], ["33%", ""], ["", "", ""], ["25%", "75%"]]) { + const result = parse(widths.map((width, index) => `Column ${index}`).join("")); + expect(result.ok).toBe(true); + if (!result.ok) continue; + const row = result.value.root.slots.content[0]!.slots.content[0]!; + expect(row.type).toBe("layout.columns"); + expect(row.slots.columns.map(column => column.slots.content[0]!.props.text)).toEqual(widths.map((_, index) => `Column ${index}`)); + expect(row.slots.columns.reduce((sum, column) => sum + Number.parseFloat(String(column.props.width)), 0)).toBeCloseTo(100, 8); + const rendered = await renderEmail(result.value, { mjml: true }); + expect(rendered.ok).toBe(true); + if (rendered.ok) expect(rendered.value.exports?.mjml).toContain(" { + for (const input of [ + 'Bad', + '', + '', + '', + Array(5).fill('X').join(""), + ]) expect(parse(input).ok).toBe(false); +}); + +test("import enforces depth limits and rejects repeated instance IDs", () => { + const nested = '' + ''.repeat(100) + 'X' + ''.repeat(100) + ''; + expect(importMjml(nested, { subject: "Depth", id: () => "id" }).ok).toBe(false); + expect(importMjml('X', { subject: "IDs", id: () => "same" }).ok).toBe(false); +});