384 lines
14 KiB
TypeScript
384 lines
14 KiB
TypeScript
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;
|
|
attributes?: Record<string, string>;
|
|
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<string, unknown>,
|
|
) => MjmlAst;
|
|
const compileMjml = require("mjml") as (
|
|
value: string,
|
|
options?: Record<string, unknown>,
|
|
) => 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<BlockDocument, MjmlDiagnostic[]> {
|
|
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 (/<mj-(?:raw|include)\b|<script\b|\{[%{[]/i.test(source))
|
|
return {
|
|
ok: false,
|
|
errors: [
|
|
{
|
|
code: "mjml.unsafe",
|
|
message: "MJML raw/include, scripts, and template syntax are not supported.",
|
|
severity: "error",
|
|
},
|
|
],
|
|
};
|
|
let ast: MjmlAst;
|
|
try {
|
|
ast = parseMjml(source, { ignoreIncludes: true, validationLevel: "strict" });
|
|
} catch (error) {
|
|
return {
|
|
ok: false,
|
|
errors: [
|
|
{
|
|
code: "mjml.parse",
|
|
message: error instanceof Error ? error.message : "Invalid MJML.",
|
|
severity: "error",
|
|
},
|
|
],
|
|
};
|
|
}
|
|
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 decode = (value: string) => value.replace(/&(?:#(x[0-9a-f]+|[0-9]+)|(amp|lt|gt|quot|apos));/gi, (original, numeric: string | undefined, name: string | undefined) => {
|
|
if (numeric) {
|
|
const point = numeric[0]!.toLowerCase() === "x" ? Number.parseInt(numeric.slice(1), 16) : Number(numeric);
|
|
return point > 0 && point <= 0x10ffff && !(point >= 0xd800 && point <= 0xdfff) ? String.fromCodePoint(point) : original;
|
|
}
|
|
return ({ amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" } as Record<string, string>)[name!.toLowerCase()]!;
|
|
});
|
|
const brand: Record<string, string> = {};
|
|
let importedSubject = "", importedPreheader = "";
|
|
const attributeError = (node: MjmlAst, attribute?: string) => errors.push({ code: "mjml.header", message: `Unsupported MJML header setting ${node.tagName}${attribute ? `.${attribute}` : ""}.`, severity: "error", element: node.tagName, attribute });
|
|
const onlyAttributes = (node: MjmlAst, allowed: string[]) => {
|
|
for (const key of Object.keys(node.attributes ?? {})) if (!allowed.includes(key)) attributeError(node, key);
|
|
};
|
|
for (const head of (ast.children ?? []).filter(node => node.tagName === "mj-head")) {
|
|
onlyAttributes(head, []);
|
|
for (const node of head.children ?? []) {
|
|
if (node.tagName === "mj-title" || node.tagName === "mj-preview") {
|
|
onlyAttributes(node, []);
|
|
if (node.tagName === "mj-title") importedSubject = decode(node.content ?? "");
|
|
else importedPreheader = decode(node.content ?? "");
|
|
} else if (node.tagName === "mj-attributes") {
|
|
onlyAttributes(node, []);
|
|
for (const defaults of node.children ?? []) {
|
|
if (defaults.tagName === "mj-all") {
|
|
onlyAttributes(defaults, ["font-family"]);
|
|
if (defaults.attributes?.["font-family"]) brand.fontFamily = decode(defaults.attributes["font-family"]);
|
|
} else if (defaults.tagName === "mj-text") {
|
|
onlyAttributes(defaults, ["color"]);
|
|
if (defaults.attributes?.color) brand.textColor = decode(defaults.attributes.color);
|
|
} else if (defaults.tagName === "mj-button") {
|
|
onlyAttributes(defaults, ["background-color"]);
|
|
if (defaults.attributes?.["background-color"]) brand.linkColor = decode(defaults.attributes["background-color"]);
|
|
} else attributeError(defaults);
|
|
}
|
|
} else if (node.tagName === "mj-breakpoint") {
|
|
onlyAttributes(node, ["width"]);
|
|
if (node.attributes?.width !== "480px") attributeError(node, "width");
|
|
} else attributeError(node);
|
|
}
|
|
}
|
|
const bodyAttributes = (ast.children ?? []).find(node => node.tagName === "mj-body")?.attributes ?? {};
|
|
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 (node.tagName === "mj-head") return [];
|
|
if (!supported.has(node.tagName)) {
|
|
errors.push({
|
|
code: "mjml.component",
|
|
message: `Unsupported MJML component ${node.tagName}.`,
|
|
severity: "error",
|
|
element: node.tagName,
|
|
});
|
|
return [];
|
|
}
|
|
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 (
|
|
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 ?? "" },
|
|
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: decode(a.src ?? ""),
|
|
alt: decode(a.alt ?? ""),
|
|
width: (a.width ?? "600").replace(/px$/, ""),
|
|
height: (a.height ?? "300").replace(/px$/, ""),
|
|
decorative: false,
|
|
},
|
|
styles: {},
|
|
slots: {},
|
|
},
|
|
];
|
|
if (node.tagName === "mj-button") {
|
|
const buttonStyles: BlockNode["styles"] = {};
|
|
const mappings: Record<string, [string, RegExp]> = {
|
|
align: ["textAlign", /^(left|center|right|start|end)$/],
|
|
"background-color": ["backgroundColor", /^(#[0-9a-f]{3,8}|rgba?\([0-9., %]+\))$/i],
|
|
color: ["color", /^(#[0-9a-f]{3,8}|rgba?\([0-9., %]+\))$/i],
|
|
"font-size": ["fontSize", /^\d+(?:\.\d+)?(?:px|rem|em|%)$/],
|
|
"font-weight": ["fontWeight", /^(normal|bold|[1-9]00)$/],
|
|
"border-radius": ["borderRadius", /^\d+(?:\.\d+)?(?:px|rem|em|%)$/],
|
|
"inner-padding": ["padding", /^\d+(?:\.\d+)?(?:px|rem|em|%)(?:\s+\d+(?:\.\d+)?(?:px|rem|em|%)){0,3}$/],
|
|
};
|
|
for (const [attribute, raw] of Object.entries(a)) {
|
|
if (attribute === "href") continue;
|
|
const mapping = mappings[attribute];
|
|
const value = decode(raw).trim();
|
|
if (!mapping || value.length > 80 || !mapping[1].test(value)) {
|
|
errors.push({ code: "mjml.button-attribute", message: `Unsupported button attribute or value: ${attribute}.`, severity: "error", element: node.tagName, attribute });
|
|
} else buttonStyles[mapping[0]] = { base: value };
|
|
}
|
|
return [
|
|
{
|
|
id,
|
|
type: "content.button",
|
|
version: 1,
|
|
props: { label: decode(node.content ?? "Button"), href: decode(a.href ?? "") },
|
|
styles: buttonStyles,
|
|
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: (bodyAttributes.width ?? "600").replace(/px$/, ""), background: bodyAttributes["background-color"] ?? "#f3f4f6", ...brand },
|
|
styles: {},
|
|
slots: {
|
|
content: content.map((node) =>
|
|
node.type === "layout.column"
|
|
? {
|
|
id: options.id(),
|
|
type: "layout.columns",
|
|
version: 1,
|
|
props: {},
|
|
styles: {},
|
|
slots: { columns: [node] },
|
|
}
|
|
: node,
|
|
),
|
|
},
|
|
};
|
|
const document: BlockDocument = {
|
|
schemaVersion: 1,
|
|
kind: "email",
|
|
meta: { subject: options.subject ?? (importedSubject || "Imported email"), preheader: options.preheader ?? importedPreheader, 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,
|
|
};
|
|
}
|
|
|
|
export async function validateMjml(source: string): Promise<MjmlDiagnostic[]> {
|
|
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",
|
|
},
|
|
];
|
|
}
|
|
}
|