Normalize imported column rows and validate MJML documents
Quality / quality (ubuntu-latest) (push) Failing after 10m1s
Quality / quality (windows-latest) (push) Canceled after 0s

This commit is contained in:
Clintchiz committed 2026-09-05 17:06:23 +05:30
1 parent 4e0f5adb5f
commit 69db4bd62e
3 files changed
+89 -7

No files matched your search

+50 -6
View File
@@ -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,
};
}