diff --git a/packages/email-renderer/package.json b/packages/email-renderer/package.json index d5a1c873..c83ce02a 100644 --- a/packages/email-renderer/package.json +++ b/packages/email-renderer/package.json @@ -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": { diff --git a/packages/email-renderer/src/mjml.ts b/packages/email-renderer/src/mjml.ts index 65359e04..9d44cfac 100644 --- a/packages/email-renderer/src/mjml.ts +++ b/packages/email-renderer/src/mjml.ts @@ -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 { 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)[name!.toLowerCase()]!; + }); + const brand: Record = {}; + 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, }; diff --git a/packages/email-renderer/test/mjml-brand.test.ts b/packages/email-renderer/test/mjml-brand.test.ts index ad26147f..b2b28d9e 100644 --- a/packages/email-renderer/test/mjml-brand.test.ts +++ b/packages/email-renderer/test/mjml-brand.test.ts @@ -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(""); 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 [ + '', + '', + '', + '.test{color:red}', + '', + ]) { + let id = 0; + expect(importMjml(`${header}Text`, { id: () => `n${++id}` }).ok).toBe(false); + } });