fix(email-renderer): preserve AMP section and spacer layout
This commit is contained in:
1 parent
6b35f522b9
commit
5e30d6a7d0
4 files changed
+42
-5
No files matched your search
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/email-renderer",
|
"name": "@wrnexus/email-renderer",
|
||||||
"version": "0.1.20",
|
"version": "0.1.21",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "WRNexus block email renderers with HTML, text, AMP Email, and MJML interoperability.",
|
"description": "WRNexus block email renderers with HTML, text, AMP Email, and MJML interoperability.",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -32,6 +32,22 @@ const html = (value: unknown) =>
|
|||||||
.replaceAll('"', """)
|
.replaceAll('"', """)
|
||||||
.replaceAll("'", "'");
|
.replaceAll("'", "'");
|
||||||
const attrs = (node: BlockNode) => node.props as Record<string, unknown>;
|
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 {
|
||||||
|
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, "<br />");
|
const multilineText = (value: unknown) => html(value).replace(/\r\n?|\n/g, "<br />");
|
||||||
const imageDimension = (fallback: string): ValueSchema<string> => ({
|
const imageDimension = (fallback: string): ValueSchema<string> => ({
|
||||||
kind: "string",
|
kind: "string",
|
||||||
@@ -155,7 +171,7 @@ export const sectionBlock = defineBlock({
|
|||||||
),
|
),
|
||||||
ampEmail: ({ node, slots }) =>
|
ampEmail: ({ node, slots }) =>
|
||||||
emailCell(
|
emailCell(
|
||||||
`<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td class="section"><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 class="${ampLayoutClass(node)}"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">${slots.content}</table></td></tr></table>`,
|
||||||
node,
|
node,
|
||||||
),
|
),
|
||||||
mjml: ({ node, slots }) => {
|
mjml: ({ node, slots }) => {
|
||||||
@@ -382,7 +398,7 @@ export const spacerBlock = leaf(
|
|||||||
`<div style="font-size:1px;height:${html(attrs(node).height)}px;line-height:${html(attrs(node).height)}px;"> </div>`,
|
`<div style="font-size:1px;height:${html(attrs(node).height)}px;line-height:${html(attrs(node).height)}px;"> </div>`,
|
||||||
node,
|
node,
|
||||||
),
|
),
|
||||||
ampEmail: ({ node }) => emailCell(`<div class="spacer"> </div>`, node),
|
ampEmail: ({ node }) => emailCell(`<div class="${ampLayoutClass(node)}"> </div>`, node),
|
||||||
mjml: ({ node }) => `<mj-spacer height="${html(attrs(node).height)}px" />`,
|
mjml: ({ node }) => `<mj-spacer height="${html(attrs(node).height)}px" />`,
|
||||||
},
|
},
|
||||||
() => "\n",
|
() => "\n",
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
type Diagnostic,
|
type Diagnostic,
|
||||||
type Result,
|
type Result,
|
||||||
} from "@wrnexus/blocks";
|
} from "@wrnexus/blocks";
|
||||||
import { emailCoreBlocks } from "./blocks.ts";
|
import { emailCoreBlocks, ampLayoutCss } from "./blocks.ts";
|
||||||
|
|
||||||
interface AmpValidationError {
|
interface AmpValidationError {
|
||||||
severity: string;
|
severity: string;
|
||||||
@@ -80,9 +80,10 @@ 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>`;
|
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 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<string, unknown>;
|
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"}}`;
|
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}.section{padding:24px}.button{background:#2563eb;border-radius:6px;color:#fff;display:inline-block;font-weight:700;padding:12px 20px;text-decoration:none}.spacer{height:24px}</style></head><body>${body}</body></html>`;
|
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>`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function validateAmpEmail(content: string): Promise<ValidationReport> {
|
export async function validateAmpEmail(content: string): Promise<ValidationReport> {
|
||||||
|
|||||||
@@ -10,6 +10,26 @@ import {
|
|||||||
type EmailRenderBundle,
|
type EmailRenderBundle,
|
||||||
} from "../src/index.ts";
|
} from "../src/index.ts";
|
||||||
import type { BlockDocument } from "@wrnexus/blocks";
|
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: "</style>" } })).not.toContain("display:none");
|
||||||
|
});
|
||||||
|
|
||||||
const document: BlockDocument = {
|
const document: BlockDocument = {
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
|
|||||||
Reference in new issue
Block a user