diff --git a/packages/email-renderer/package.json b/packages/email-renderer/package.json index 589b36d7..dd62ef9b 100644 --- a/packages/email-renderer/package.json +++ b/packages/email-renderer/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/email-renderer", - "version": "0.1.20", + "version": "0.1.21", "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 2e820dfb..960b3832 100644 --- a/packages/email-renderer/src/blocks.ts +++ b/packages/email-renderer/src/blocks.ts @@ -32,6 +32,22 @@ const html = (value: unknown) => .replaceAll('"', """) .replaceAll("'", "'"); const attrs = (node: BlockNode) => node.props as Record; +export const ampLayoutClass = (node: BlockNode) => + "wrn-layout-" + Array.from(node.id, char => char.codePointAt(0)!.toString(16)).join("-"); + +export function ampLayoutCss(node: BlockNode): string { + const props = attrs(node); + if (node.type === "layout.section") { + const background = safeCssValue(props.background, /^(#[0-9a-f]{3,8}|rgba?\([0-9., %]+\))$/i) || "#ffffff"; + const padding = safeCssValue(props.padding, /^\d+(?:\.\d+)?(?:px|rem|em|%)?(?:\s+\d+(?:\.\d+)?(?:px|rem|em|%)?){0,3}$/) || "24px"; + return `.${ampLayoutClass(node)}{background:${background};padding:${padding}}`; + } + if (node.type === "content.spacer") { + const height = safeCssValue(props.height, /^\d{1,4}$/) || "24"; + return `.${ampLayoutClass(node)}{height:${height}px;line-height:${height}px;font-size:1px}`; + } + return ""; +} const multilineText = (value: unknown) => html(value).replace(/\r\n?|\n/g, "
"); const imageDimension = (fallback: string): ValueSchema => ({ kind: "string", @@ -155,7 +171,7 @@ export const sectionBlock = defineBlock({ ), ampEmail: ({ node, slots }) => emailCell( - `
${slots.content}
`, + `
${slots.content}
`, node, ), mjml: ({ node, slots }) => { @@ -382,7 +398,7 @@ export const spacerBlock = leaf( `
 
`, node, ), - ampEmail: ({ node }) => emailCell(`
 
`, node), + ampEmail: ({ node }) => emailCell(`
 
`, node), mjml: ({ node }) => ``, }, () => "\n", diff --git a/packages/email-renderer/src/email.ts b/packages/email-renderer/src/email.ts index 1efa121e..d25d61fb 100644 --- a/packages/email-renderer/src/email.ts +++ b/packages/email-renderer/src/email.ts @@ -12,7 +12,7 @@ import { type Diagnostic, type Result, } from "@wrnexus/blocks"; -import { emailCoreBlocks } from "./blocks.ts"; +import { emailCoreBlocks, ampLayoutCss } from "./blocks.ts"; interface AmpValidationError { severity: string; @@ -80,9 +80,10 @@ const head = (document: BlockDocument, body: string, direction: "ltr" | "rtl") = return `${escapeHtml(document.meta.subject)}${document.meta.preheader ? `
${escapeHtml(document.meta.preheader)}
` : ""}${body}`; }; const ampHead = (document: BlockDocument, body: string, direction: "ltr" | "rtl") => { + const layoutCss = (node: BlockDocument["root"]): string => ampLayoutCss(node) + Object.values(node.slots).flat().map(layoutCss).join(""); const props = document.root.props as Record; const css = `@media only screen and (max-width:480px){.email-body .wrn-columns{display:block}.email-body .wrn-column{display:block;width:100%}}.marketing-image img{object-fit:contain}body{margin:0;color:${props.textColor ?? "#111827"};font-family:${props.fontFamily ?? "Arial, sans-serif"}}a{color:${props.linkColor ?? "#2563eb"}}.email-bg{background:${props.background ?? "#f3f4f6"}}.email-body{max-width:${props.width ?? "600"}px;width:100%;background:${props.contentBackground ?? "#ffffff"}}`; - return `${body}`; + return `${body}`; }; export async function validateAmpEmail(content: string): Promise { diff --git a/packages/email-renderer/test/email.test.ts b/packages/email-renderer/test/email.test.ts index 0f756877..988ee5b8 100644 --- a/packages/email-renderer/test/email.test.ts +++ b/packages/email-renderer/test/email.test.ts @@ -10,6 +10,26 @@ import { type EmailRenderBundle, } from "../src/index.ts"; import type { BlockDocument } from "@wrnexus/blocks"; +import { ampLayoutClass, ampLayoutCss } from "../src/blocks.ts"; + +test("AMP section and spacer CSS retains configured layout values", async () => { + const draft = structuredClone(document); + const section = draft.root.slots.content[0]!; + section.props = { background: "#abcdef", padding: "12px 30px" }; + const spacer = { id: 'spacer:one"', type: "content.spacer", version: 1, props: { height: "77" }, styles: {}, slots: {} }; + section.slots.content = [spacer]; + const result = await renderEmail(draft, { amp: true, mjml: true }); + expect(result.ok).toBe(true); + if (!result.ok) return; + const amp = result.value.parts.amp; + expect(amp?.validation.valid).toBe(true); + expect(amp?.content).toContain(`.${ampLayoutClass(section)}{background:#abcdef;padding:12px 30px}`); + expect(amp?.content).toContain(`.${ampLayoutClass(spacer)}{height:77px;line-height:77px;font-size:1px}`); + expect(amp?.content).toContain(`class="${ampLayoutClass(spacer)}"`); + expect(result.value.parts.html.content).toContain("height:77px"); + expect(result.value.exports?.mjml).toContain('height="77px"'); + expect(ampLayoutCss({ ...section, props: { background: "red;}body{display:none", padding: "" } })).not.toContain("display:none"); +}); const document: BlockDocument = { schemaVersion: 1,