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",
|
||||
"version": "0.1.31",
|
||||
"version": "0.1.32",
|
||||
"type": "module",
|
||||
"description": "WRNexus block email renderers with HTML, text, AMP Email, and MJML interoperability.",
|
||||
"exports": {
|
||||
|
||||
@@ -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 <safe> & 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);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user