diff --git a/packages/email-renderer/package.json b/packages/email-renderer/package.json index 13a722ae..ef0f24f5 100644 --- a/packages/email-renderer/package.json +++ b/packages/email-renderer/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/email-renderer", - "version": "0.1.23", + "version": "0.1.25", "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 15e3105c..f482ae66 100644 --- a/packages/email-renderer/src/blocks.ts +++ b/packages/email-renderer/src/blocks.ts @@ -35,8 +35,10 @@ 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 { +export function ampLayoutCss(node: BlockNode, brandLinkColor?: string): string { const props = attrs(node); + if (node.type === "content.button") + return `.button.${ampLayoutClass(node)}{background-color:${buttonBackground(node, { brandLinkColor })}}`; 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"; @@ -97,6 +99,11 @@ const semanticStyle = (node: BlockNode) => { if (weight) declarations.push(`font-weight:${weight}`); return declarations.join(";"); }; +const buttonBackground = (node: BlockNode, context: unknown) => { + const override = safeCssValue(styleBase(node, "backgroundColor"), /^(#[0-9a-f]{3,8}|rgba?\([0-9., %]+\))$/i); + const brand = (context as { brandLinkColor?: string } | undefined)?.brandLinkColor; + return override || (brand && brandColor("#2563eb").parse(brand).ok ? brand : "#2563eb"); +}; const emailCell = (body: string, node: BlockNode) => `${body}`; @@ -166,7 +173,7 @@ export const sectionBlock = defineBlock({ renderers: { emailHtml: ({ node, slots }) => emailCell( - `
${slots.content}
`, + `
${slots.content}
`, node, ), ampEmail: ({ node, slots }) => @@ -179,7 +186,7 @@ export const sectionBlock = defineBlock({ const body = children.length === 1 && children[0]?.type === "layout.columns" ? slots.content : `${slots.content}`; - return `${body}`; + return `${body}`; }, }, textRenderer: ({ slots }) => `${slots.content}\n`, @@ -358,18 +365,18 @@ export const buttonBlock = leaf( href: safeUrl({ schemes: ["https", "mailto"] }), }) as never, { - emailHtml: ({ node }) => + emailHtml: ({ node, context }) => emailCell( - `
${html(attrs(node).label)}
`, + `
${html(attrs(node).label)}
`, node, ), ampEmail: ({ node }) => emailCell( - `${html(attrs(node).label)}`, + `${html(attrs(node).label)}`, node, ), - mjml: ({ node }) => - `${html(attrs(node).label)}`, + mjml: ({ node, context }) => + `${html(attrs(node).label)}`, }, ({ node }) => `${String(attrs(node).label)}: ${String(attrs(node).href)}\n\n`, ); diff --git a/packages/email-renderer/src/email.ts b/packages/email-renderer/src/email.ts index bd3766ca..49426e33 100644 --- a/packages/email-renderer/src/email.ts +++ b/packages/email-renderer/src/email.ts @@ -80,7 +80,7 @@ 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 layoutCss = (node: BlockDocument["root"]): string => ampLayoutCss(node, String(document.root.props.linkColor ?? "#2563eb")) + 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}`; @@ -138,6 +138,7 @@ export async function renderEmail( timezone: options.timezone ?? "UTC", direction: options.direction ?? "ltr", countdownImageUrl: options.countdownImageUrl, + brandLinkColor: String(source.root.props.linkColor ?? "#2563eb"), }; const [htmlResult, textResult, mjmlResult] = await Promise.all([ renderTree(source, emailKind, registry, { ...base, target: "emailHtml", data: options.variables }), diff --git a/packages/email-renderer/test/button-brand.test.ts b/packages/email-renderer/test/button-brand.test.ts new file mode 100644 index 00000000..a2eaf1f9 --- /dev/null +++ b/packages/email-renderer/test/button-brand.test.ts @@ -0,0 +1,26 @@ +import { expect, test } from "bun:test"; +import type { BlockDocument } from "@wrnexus/blocks"; +import { renderEmail, validateMjml } from "../src/index.ts"; + +test("button background inherits brand color with explicit semantic overrides across formats", async () => { + for (const override of [undefined, "#aabbcc"]) { + const document: BlockDocument = { + schemaVersion: 1, kind: "email", meta: { subject: "Brand", lang: "en" }, theme: { tokens: {} }, + root: { id: "root", type: "layout.email-document", version: 1, props: { linkColor: "#125634" }, styles: {}, slots: { + content: [{ id: "button", type: "content.button", version: 1, props: { label: "Act", href: "https://example.com" }, styles: override ? { backgroundColor: { base: override } } : {}, slots: {} }], + } }, + }; + document.root.slots.content = [{ id: "section", type: "layout.section", version: 1, props: {}, styles: {}, slots: { content: document.root.slots.content } }]; + const before = structuredClone(document); + const rendered = await renderEmail(document, { amp: true, mjml: true, ampValidationTimeoutMs: 10000 }); + expect(rendered.ok).toBe(true); + if (!rendered.ok) return; + const color = override ?? "#125634"; + expect(rendered.value.parts.html.content).toContain(`bgcolor="${color}"`); + expect(rendered.value.parts.amp?.validation.valid).toBe(true); + expect(rendered.value.parts.amp?.content).toContain(`background-color:${color}`); + expect(rendered.value.exports?.mjml).toContain(`background-color="${color}"`); + expect(await validateMjml(rendered.value.exports!.mjml!)).toEqual([]); + expect(document).toEqual(before); + } +});