Import supported MJML header metadata and typography
This commit is contained in:
1 parent
69db4bd62e
commit
f5139f2c82
3 files changed
+68
-5
No files matched your search
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/email-renderer",
|
"name": "@wrnexus/email-renderer",
|
||||||
"version": "0.1.31",
|
"version": "0.1.32",
|
||||||
"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": {
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export interface MjmlDiagnostic extends Diagnostic {
|
|||||||
|
|
||||||
export function importMjml(
|
export function importMjml(
|
||||||
source: string,
|
source: string,
|
||||||
options: { id(): string; subject: string; preheader?: string },
|
options: { id(): string; subject?: string; preheader?: string },
|
||||||
): Result<BlockDocument, MjmlDiagnostic[]> {
|
): Result<BlockDocument, MjmlDiagnostic[]> {
|
||||||
if (new TextEncoder().encode(source).byteLength > DEFAULT_LIMITS.maxBytes)
|
if (new TextEncoder().encode(source).byteLength > DEFAULT_LIMITS.maxBytes)
|
||||||
return {
|
return {
|
||||||
@@ -111,6 +111,44 @@ export function importMjml(
|
|||||||
}
|
}
|
||||||
for (const child of node.children ?? []) pending.push({ node: child, depth: depth + 1 });
|
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 wrapColumns = (nodes: BlockNode[]): BlockNode[] => {
|
||||||
const output: BlockNode[] = [];
|
const output: BlockNode[] = [];
|
||||||
for (let index = 0; index < nodes.length;) {
|
for (let index = 0; index < nodes.length;) {
|
||||||
@@ -139,6 +177,7 @@ export function importMjml(
|
|||||||
return output;
|
return output;
|
||||||
};
|
};
|
||||||
const convert = (node: MjmlAst): BlockNode[] => {
|
const convert = (node: MjmlAst): BlockNode[] => {
|
||||||
|
if (node.tagName === "mj-head") return [];
|
||||||
if (!supported.has(node.tagName)) {
|
if (!supported.has(node.tagName)) {
|
||||||
errors.push({
|
errors.push({
|
||||||
code: "mjml.component",
|
code: "mjml.component",
|
||||||
@@ -243,7 +282,7 @@ export function importMjml(
|
|||||||
id: options.id(),
|
id: options.id(),
|
||||||
type: "layout.email-document",
|
type: "layout.email-document",
|
||||||
version: 1,
|
version: 1,
|
||||||
props: { width: "600", background: "#f3f4f6" },
|
props: { width: (bodyAttributes.width ?? "600").replace(/px$/, ""), background: bodyAttributes["background-color"] ?? "#f3f4f6", ...brand },
|
||||||
styles: {},
|
styles: {},
|
||||||
slots: {
|
slots: {
|
||||||
content: content.map((node) =>
|
content: content.map((node) =>
|
||||||
@@ -263,7 +302,7 @@ export function importMjml(
|
|||||||
const document: BlockDocument = {
|
const document: BlockDocument = {
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
kind: "email",
|
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: {} },
|
theme: { tokens: {} },
|
||||||
root,
|
root,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { expect, test } from "bun:test";
|
import { expect, test } from "bun:test";
|
||||||
import { createRequire } from "node:module";
|
import { createRequire } from "node:module";
|
||||||
import type { BlockDocument } from "@wrnexus/blocks";
|
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");
|
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 <safe> & useful");
|
expect(compiled.html).toContain("Preview <safe> & useful");
|
||||||
expect(compiled.html).not.toContain("<safe>");
|
expect(compiled.html).not.toContain("<safe>");
|
||||||
expect(document).toEqual(before);
|
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);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user