Import supported MJML header metadata and typography
Quality / quality (ubuntu-latest) (push) Failing after 1s
Quality / quality (windows-latest) (push) Canceled after 0s

This commit is contained in:
Clintchiz committed 2026-09-05 17:10:25 +05:30
1 parent 69db4bd62e
commit f5139f2c82
3 files changed
+68 -5

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/email-renderer",
"version": "0.1.31",
"version": "0.1.32",
"type": "module",
"description": "WRNexus block email renderers with HTML, text, AMP Email, and MJML interoperability.",
"exports": {
+42 -3
View File
@@ -67,7 +67,7 @@ export interface MjmlDiagnostic extends Diagnostic {
export function importMjml(
source: string,
options: { id(): string; subject: string; preheader?: string },
options: { id(): string; subject?: string; preheader?: string },
): Result<BlockDocument, MjmlDiagnostic[]> {
if (new TextEncoder().encode(source).byteLength > DEFAULT_LIMITS.maxBytes)
return {
@@ -111,6 +111,44 @@ export function importMjml(
}
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 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;) {
@@ -139,6 +177,7 @@ export function importMjml(
return output;
};
const convert = (node: MjmlAst): BlockNode[] => {
if (node.tagName === "mj-head") return [];
if (!supported.has(node.tagName)) {
errors.push({
code: "mjml.component",
@@ -243,7 +282,7 @@ export function importMjml(
id: options.id(),
type: "layout.email-document",
version: 1,
props: { width: "600", background: "#f3f4f6" },
props: { width: (bodyAttributes.width ?? "600").replace(/px$/, ""), background: bodyAttributes["background-color"] ?? "#f3f4f6", ...brand },
styles: {},
slots: {
content: content.map((node) =>
@@ -263,7 +302,7 @@ export function importMjml(
const document: BlockDocument = {
schemaVersion: 1,
kind: "email",
meta: { subject: options.subject, preheader: options.preheader ?? "", lang: "en" },
meta: { subject: options.subject ?? (importedSubject || "Imported email"), preheader: options.preheader ?? importedPreheader, lang: "en" },
theme: { tokens: {} },
root,
};
@@ -1,7 +1,7 @@
import { expect, test } from "bun:test";
import { createRequire } from "node:module";
import type { BlockDocument } from "@wrnexus/blocks";
import { renderEmail } from "../src/index.ts";
import { renderEmail, importMjml } from "../src/index.ts";
const compile = createRequire(import.meta.url)("mjml");
@@ -27,4 +27,28 @@ test("MJML preserves document metadata and global typography in compiled HTML",
expect(compiled.html).toContain("Preview &lt;safe&gt; &amp; useful");
expect(compiled.html).not.toContain("<safe>");
expect(document).toEqual(before);
let id = 0;
const imported = importMjml(source, { id: () => `import-${++id}` });
expect(imported.ok).toBe(true);
if (imported.ok) {
expect(imported.value.meta.subject).toBe(document.meta.subject);
expect(imported.value.meta.preheader).toBe(document.meta.preheader);
expect(imported.value.root.props.fontFamily).toBe("Georgia, serif");
expect(imported.value.root.props.textColor).toBe("#123456");
const rerendered = await renderEmail(imported.value, { mjml: true });
expect(rerendered.ok).toBe(true);
}
});
test("MJML header import rejects unsupported globals and unsafe typography", () => {
for (const header of [
'<mj-attributes><mj-all padding="20px" /></mj-attributes>',
'<mj-attributes><mj-all font-family="Arial;position:fixed" /></mj-attributes>',
'<mj-attributes><mj-text font-size="20px" /></mj-attributes>',
'<mj-style>.test{color:red}</mj-style>',
'<mj-breakpoint width="900px" />',
]) {
let id = 0;
expect(importMjml(`<mjml><mj-head>${header}</mj-head><mj-body><mj-section><mj-column><mj-text>Text</mj-text></mj-column></mj-section></mj-body></mjml>`, { id: () => `n${++id}` }).ok).toBe(false);
}
});