fix(email): inherit button brand colors across render targets
This commit is contained in:
1 parent
e1ea6e2977
commit
7afd60e812
4 files changed
+44
-10
No files matched your search
@@ -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": {
|
||||
|
||||
@@ -35,8 +35,10 @@ const attrs = (node: BlockNode) => node.props as Record<string, unknown>;
|
||||
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) =>
|
||||
`<tr><td data-block-id="${html(node.id)}" style="padding:0;${semanticStyle(node)}">${body}</td></tr>`;
|
||||
|
||||
@@ -166,7 +173,7 @@ export const sectionBlock = defineBlock({
|
||||
renderers: {
|
||||
emailHtml: ({ node, slots }) =>
|
||||
emailCell(
|
||||
`<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:${html(attrs(node).background)};padding:${html(attrs(node).padding)};"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">${slots.content}</table></td></tr></table>`,
|
||||
`<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:${html(attrs(node).background ?? "#ffffff")};padding:${html(attrs(node).padding ?? "24px")};"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">${slots.content}</table></td></tr></table>`,
|
||||
node,
|
||||
),
|
||||
ampEmail: ({ node, slots }) =>
|
||||
@@ -179,7 +186,7 @@ export const sectionBlock = defineBlock({
|
||||
const body = children.length === 1 && children[0]?.type === "layout.columns"
|
||||
? slots.content
|
||||
: `<mj-column>${slots.content}</mj-column>`;
|
||||
return `<mj-section background-color="${html(attrs(node).background)}" padding="${html(attrs(node).padding)}">${body}</mj-section>`;
|
||||
return `<mj-section background-color="${html(attrs(node).background ?? "#ffffff")}" padding="${html(attrs(node).padding ?? "24px")}">${body}</mj-section>`;
|
||||
},
|
||||
},
|
||||
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(
|
||||
`<table role="presentation"><tr><td bgcolor="#2563eb" style="border-radius:6px;"><a href="${html(attrs(node).href)}" style="color:#ffffff;display:inline-block;font-family:inherit;font-weight:700;padding:12px 20px;text-decoration:none;">${html(attrs(node).label)}</a></td></tr></table>`,
|
||||
`<table role="presentation"><tr><td bgcolor="${html(buttonBackground(node, context))}" style="border-radius:6px;"><a href="${html(attrs(node).href)}" style="color:#ffffff;display:inline-block;font-family:inherit;font-weight:700;padding:12px 20px;text-decoration:none;">${html(attrs(node).label)}</a></td></tr></table>`,
|
||||
node,
|
||||
),
|
||||
ampEmail: ({ node }) =>
|
||||
emailCell(
|
||||
`<a class="button" href="${html(attrs(node).href)}">${html(attrs(node).label)}</a>`,
|
||||
`<a class="button ${ampLayoutClass(node)}" href="${html(attrs(node).href)}">${html(attrs(node).label)}</a>`,
|
||||
node,
|
||||
),
|
||||
mjml: ({ node }) =>
|
||||
`<mj-button href="${html(attrs(node).href)}">${html(attrs(node).label)}</mj-button>`,
|
||||
mjml: ({ node, context }) =>
|
||||
`<mj-button background-color="${html(buttonBackground(node, context))}" href="${html(attrs(node).href)}">${html(attrs(node).label)}</mj-button>`,
|
||||
},
|
||||
({ node }) => `${String(attrs(node).label)}: ${String(attrs(node).href)}\n\n`,
|
||||
);
|
||||
|
||||
@@ -80,7 +80,7 @@ const head = (document: BlockDocument, body: string, direction: "ltr" | "rtl") =
|
||||
return `<!doctype html><html lang="${escapeHtml(document.meta.lang)}" dir="${direction}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="x-apple-disable-message-reformatting"><meta name="color-scheme" content="light dark"><title>${escapeHtml(document.meta.subject)}</title><style>a{color:${linkColor}}@media only screen and (max-width:480px){.wrn-email{width:100%!important}.wrn-columns{display:block!important}.wrn-column{display:block!important;width:100%!important}}</style></head><body style="margin:0;padding:0;color:${textColor};font-family:${fontFamily};">${document.meta.preheader ? `<div style="display:none;max-height:0;overflow:hidden;opacity:0;">${escapeHtml(document.meta.preheader)}</div>` : ""}${body}</body></html>`;
|
||||
};
|
||||
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<string, unknown>;
|
||||
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 `<!doctype html><html amp4email data-css-strict lang="${escapeHtml(document.meta.lang)}" dir="${direction}"><head><meta charset="utf-8"><script async src="https://cdn.ampproject.org/v0.js"></script><style amp4email-boilerplate>body{visibility:hidden}</style><style amp-custom>${css}${layoutCss(document.root)}.button{background:#2563eb;border-radius:6px;color:#fff;display:inline-block;font-weight:700;padding:12px 20px;text-decoration:none}</style></head><body>${body}</body></html>`;
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user