Normalize imported column rows and validate MJML documents
This commit is contained in:
1 parent
4e0f5adb5f
commit
69db4bd62e
3 files changed
+89
-7
No files matched your search
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/email-renderer",
|
"name": "@wrnexus/email-renderer",
|
||||||
"version": "0.1.30",
|
"version": "0.1.31",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "WRNexus block email renderers with HTML, text, AMP Email, and MJML interoperability.",
|
"description": "WRNexus block email renderers with HTML, text, AMP Email, and MJML interoperability.",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { createRequire } from "node:module";
|
import { createRequire } from "node:module";
|
||||||
import {
|
import {
|
||||||
DEFAULT_LIMITS,
|
DEFAULT_LIMITS,
|
||||||
|
registryForKind,
|
||||||
|
validateDocument,
|
||||||
type BlockDocument,
|
type BlockDocument,
|
||||||
type BlockNode,
|
type BlockNode,
|
||||||
type Diagnostic,
|
type Diagnostic,
|
||||||
type Result,
|
type Result,
|
||||||
} from "@wrnexus/blocks";
|
} from "@wrnexus/blocks";
|
||||||
|
import { emailKind } from "./email.ts";
|
||||||
|
|
||||||
interface MjmlAst {
|
interface MjmlAst {
|
||||||
tagName: string;
|
tagName: string;
|
||||||
@@ -98,6 +101,43 @@ export function importMjml(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
const errors: MjmlDiagnostic[] = [];
|
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[] => {
|
const convert = (node: MjmlAst): BlockNode[] => {
|
||||||
if (!supported.has(node.tagName)) {
|
if (!supported.has(node.tagName)) {
|
||||||
errors.push({
|
errors.push({
|
||||||
@@ -108,7 +148,8 @@ export function importMjml(
|
|||||||
});
|
});
|
||||||
return [];
|
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")
|
if (node.tagName === "mjml" || node.tagName === "mj-head" || node.tagName === "mj-body")
|
||||||
return children;
|
return children;
|
||||||
if (
|
if (
|
||||||
@@ -137,7 +178,7 @@ export function importMjml(
|
|||||||
id,
|
id,
|
||||||
type: "layout.column",
|
type: "layout.column",
|
||||||
version: 1,
|
version: 1,
|
||||||
props: { width: a.width ?? "100%" },
|
props: { width: a.width ?? "" },
|
||||||
styles: {},
|
styles: {},
|
||||||
slots: { content: children },
|
slots: { content: children },
|
||||||
},
|
},
|
||||||
@@ -219,15 +260,18 @@ export function importMjml(
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
return {
|
const document: BlockDocument = {
|
||||||
ok: true,
|
|
||||||
value: {
|
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
kind: "email",
|
kind: "email",
|
||||||
meta: { subject: options.subject, preheader: options.preheader ?? "", lang: "en" },
|
meta: { subject: options.subject, preheader: options.preheader ?? "", lang: "en" },
|
||||||
theme: { tokens: {} },
|
theme: { tokens: {} },
|
||||||
root,
|
root,
|
||||||
},
|
};
|
||||||
|
const validated = validateDocument(document, emailKind, registryForKind(emailKind).freeze());
|
||||||
|
if (!validated.ok) return { ok: false, errors: validated.errors };
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
value: document,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(`<mjml><mj-body><mj-section>${columns}</mj-section></mj-body></mjml>`, { 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) => `<mj-column${width ? ` width="${width}"` : ""}><mj-text>Column ${index}</mj-text></mj-column>`).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("<mj-column");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("import rejects invalid URLs, dimensions, widths, and native slot limits", () => {
|
||||||
|
for (const input of [
|
||||||
|
'<mj-column><mj-button href="javascript:alert(1)">Bad</mj-button></mj-column>',
|
||||||
|
'<mj-column><mj-image src="https://example.com/x.png" width="99999px" /></mj-column>',
|
||||||
|
'<mj-column width="75%"></mj-column><mj-column width="75%"></mj-column>',
|
||||||
|
'<mj-column width="400px"></mj-column>',
|
||||||
|
Array(5).fill('<mj-column><mj-text>X</mj-text></mj-column>').join(""),
|
||||||
|
]) expect(parse(input).ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("import enforces depth limits and rejects repeated instance IDs", () => {
|
||||||
|
const nested = '<mjml><mj-body>' + '<mj-wrapper>'.repeat(100) + '<mj-section><mj-column><mj-text>X</mj-text></mj-column></mj-section>' + '</mj-wrapper>'.repeat(100) + '</mj-body></mjml>';
|
||||||
|
expect(importMjml(nested, { subject: "Depth", id: () => "id" }).ok).toBe(false);
|
||||||
|
expect(importMjml('<mjml><mj-body><mj-section><mj-column><mj-text>X</mj-text></mj-column></mj-section></mj-body></mjml>', { subject: "IDs", id: () => "same" }).ok).toBe(false);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user