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);
+});