diff --git a/packages/email-renderer/package.json b/packages/email-renderer/package.json index ef0f24f5..68f92596 100644 --- a/packages/email-renderer/package.json +++ b/packages/email-renderer/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/email-renderer", - "version": "0.1.25", + "version": "0.1.26", "type": "module", "description": "WRNexus block email renderers with HTML, text, AMP Email, and MJML interoperability.", "exports": { diff --git a/packages/email-renderer/src/blocks.ts b/packages/email-renderer/src/blocks.ts index f482ae66..264e009e 100644 --- a/packages/email-renderer/src/blocks.ts +++ b/packages/email-renderer/src/blocks.ts @@ -32,6 +32,8 @@ const html = (value: unknown) => .replaceAll('"', """) .replaceAll("'", "'"); const attrs = (node: BlockNode) => node.props as Record; +const isEmailRootChild = (node: BlockNode, context: unknown) => + (context as { rootBlockIds?: string[] } | undefined)?.rootBlockIds?.includes(node.id) ?? false; export const ampLayoutClass = (node: BlockNode) => "wrn-layout-" + Array.from(node.id, char => char.codePointAt(0)!.toString(16)).join("-"); @@ -228,7 +230,9 @@ export const columnsBlock = defineBlock({ ), ampEmail: ({ node, slots }) => emailCell(`${slots.columns}
`, node), - mjml: ({ slots }) => slots.columns, + mjml: ({ node, slots, context }) => isEmailRootChild(node, context) + ? `${slots.columns}` + : slots.columns, }, textRenderer: ({ slots }) => slots.columns, } satisfies BlockDefinition); @@ -285,7 +289,15 @@ const leaf = ( slots: {}, }, create: ({ id }) => ({ id: id(), type, version: 1, props: {}, styles: {}, slots: {} }), - renderers, + renderers: { + ...renderers, + ...(renderers?.mjml ? { mjml: async (input) => { + const content = await renderers.mjml!(input); + return isEmailRootChild(input.node, input.context) + ? `${content}` + : content; + } } : {}), + }, textRenderer, }); diff --git a/packages/email-renderer/src/email.ts b/packages/email-renderer/src/email.ts index 49426e33..bea04d2f 100644 --- a/packages/email-renderer/src/email.ts +++ b/packages/email-renderer/src/email.ts @@ -139,6 +139,7 @@ export async function renderEmail( direction: options.direction ?? "ltr", countdownImageUrl: options.countdownImageUrl, brandLinkColor: String(source.root.props.linkColor ?? "#2563eb"), + rootBlockIds: Object.values(source.root.slots).flat().map(node => node.id), }; const [htmlResult, textResult, mjmlResult] = await Promise.all([ renderTree(source, emailKind, registry, { ...base, target: "emailHtml", data: options.variables }), diff --git a/packages/email-renderer/test/root-mjml.test.ts b/packages/email-renderer/test/root-mjml.test.ts new file mode 100644 index 00000000..8baa8045 --- /dev/null +++ b/packages/email-renderer/test/root-mjml.test.ts @@ -0,0 +1,21 @@ +import { expect, test } from "bun:test"; +import type { BlockDocument, BlockNode } from "@wrnexus/blocks"; +import { renderEmail, validateMjml } from "../src/index.ts"; + +const text = (id: string): BlockNode => ({ id, type: "content.text", version: 1, props: { text: id }, styles: {}, slots: {} }); +const columns = (id: string): BlockNode => ({ id, type: "layout.columns", version: 1, props: {}, styles: {}, slots: { columns: [0, 1].map(index => ({ id: `${id}-${index}`, type: "layout.column", version: 1, props: { width: "50%" }, styles: {}, slots: { content: [text(`${id}-text-${index}`)] } })) } }); + +test("root content and separate column rows produce valid ordered MJML without changing the document", async () => { + const document: BlockDocument = { schemaVersion: 1, kind: "email", meta: { subject: "Root blocks", lang: "en" }, theme: { tokens: {} }, root: { id: "root", type: "layout.email-document", version: 1, props: {}, styles: {}, slots: { content: [text("intro"), columns("first"), columns("second"), { id: "cta", type: "content.button", version: 1, props: { label: "Act", href: "https://example.com" }, styles: {}, slots: {} }, text("end")] } } }; + const before = structuredClone(document); + const rendered = await renderEmail(document, { mjml: true }); + expect(rendered.ok).toBe(true); + if (!rendered.ok) return; + const mjml = rendered.value.exports!.mjml!; + expect(await validateMjml(mjml)).toEqual([]); + expect(mjml.match(/Act<")); + expect(document).toEqual(before); +});