Expose browser-safe email preview rendering
This commit is contained in:
1 parent
1f90e01e6e
commit
e019ac34c8
4 files changed
+98
-26
No files matched your search
@@ -1,11 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/email-renderer",
|
"name": "@wrnexus/email-renderer",
|
||||||
"version": "0.1.43",
|
"version": "0.1.44",
|
||||||
"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": {
|
||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
"./blocks": "./src/blocks.ts",
|
"./blocks": "./src/blocks.ts",
|
||||||
|
"./browser": "./src/browser.ts",
|
||||||
"./create": "./src/create.ts",
|
"./create": "./src/create.ts",
|
||||||
"./style-values": "./src/style-values.ts"
|
"./style-values": "./src/style-values.ts"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
/** Browser-safe HTML/text rendering. Production AMP/MJML validation stays on the server. */
|
||||||
|
import {
|
||||||
|
DEFAULT_LIMITS, defineDocumentKind, escapeHtml, object, registryForKind,
|
||||||
|
resolveDocumentVariables, renderTree, string, type BlockDocument,
|
||||||
|
} from "@wrnexus/blocks";
|
||||||
|
import { emailCoreBlocks } from "./blocks.ts";
|
||||||
|
export const emailKind = defineDocumentKind({
|
||||||
|
kind: "email",
|
||||||
|
version: 1,
|
||||||
|
metadata: object({
|
||||||
|
subject: string({ min: 1, max: 998 }),
|
||||||
|
preheader: string({ max: 500, default: "" }),
|
||||||
|
lang: string({ max: 35, default: "en" }),
|
||||||
|
}) as never,
|
||||||
|
rootTypes: ["layout.email-document"],
|
||||||
|
blocks: emailCoreBlocks,
|
||||||
|
variables: { recipientName: "string", unsubscribeUrl: "string", organizationAddress: "string" },
|
||||||
|
breakpoints: { compact: 480 },
|
||||||
|
capabilities: ["email-html", "plain-text", "amp-email", "mjml"],
|
||||||
|
targets: ["emailHtml", "plainText", "ampEmail", "mjml"],
|
||||||
|
limits: { ...DEFAULT_LIMITS, maxOutputBytes: 2_000_000 },
|
||||||
|
urlSchemes: ["https", "mailto"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const emailHtmlHead = (document: BlockDocument, body: string, direction: "ltr" | "rtl") => {
|
||||||
|
const props = document.root.props as Record<string, unknown>;
|
||||||
|
const textColor = escapeHtml(props.textColor ?? "#111827");
|
||||||
|
const linkColor = escapeHtml(props.linkColor ?? "#2563eb");
|
||||||
|
const fontFamily = escapeHtml(props.fontFamily ?? "Arial, sans-serif");
|
||||||
|
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>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function renderEmailPreview(document: BlockDocument, options: {
|
||||||
|
variables?: Record<string, unknown>;
|
||||||
|
direction?: "ltr" | "rtl";
|
||||||
|
countdownImageUrl?: string;
|
||||||
|
} = {}) {
|
||||||
|
const resolved = resolveDocumentVariables(document, emailKind.variables, options.variables);
|
||||||
|
if (!resolved.ok) return resolved;
|
||||||
|
const source = resolved.value;
|
||||||
|
const context = {
|
||||||
|
locale: "en", timezone: "UTC", direction: options.direction ?? "ltr",
|
||||||
|
brandLinkColor: String(source.root.props.linkColor ?? "#2563eb"),
|
||||||
|
rootBlockIds: Object.values(source.root.slots).flat().map(node => node.id),
|
||||||
|
countdownImageUrl: options.countdownImageUrl,
|
||||||
|
} as const;
|
||||||
|
const registry = registryForKind(emailKind).freeze();
|
||||||
|
const [html, text] = await Promise.all([
|
||||||
|
renderTree(source, emailKind, registry, { ...context, target: "emailHtml" }),
|
||||||
|
renderTree(source, emailKind, registry, { ...context, target: "plainText" }),
|
||||||
|
]);
|
||||||
|
if (!html.ok) return html;
|
||||||
|
if (!text.ok) return text;
|
||||||
|
return { ok: true as const, value: {
|
||||||
|
html: emailHtmlHead(source, html.value.content, context.direction),
|
||||||
|
text: text.value.content.trimEnd() + "\n",
|
||||||
|
} };
|
||||||
|
}
|
||||||
@@ -55,31 +55,8 @@ export interface EmailRenderBundle {
|
|||||||
assets: string[];
|
assets: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const emailKind = defineDocumentKind({
|
export { emailKind } from "./browser.ts";
|
||||||
kind: "email",
|
import { emailKind, emailHtmlHead as head } from "./browser.ts";
|
||||||
version: 1,
|
|
||||||
metadata: object({
|
|
||||||
subject: string({ min: 1, max: 998 }),
|
|
||||||
preheader: string({ max: 500, default: "" }),
|
|
||||||
lang: string({ max: 35, default: "en" }),
|
|
||||||
}) as never,
|
|
||||||
rootTypes: ["layout.email-document"],
|
|
||||||
blocks: emailCoreBlocks,
|
|
||||||
variables: { recipientName: "string", unsubscribeUrl: "string", organizationAddress: "string" },
|
|
||||||
breakpoints: { compact: 480 },
|
|
||||||
capabilities: ["email-html", "plain-text", "amp-email", "mjml"],
|
|
||||||
targets: ["emailHtml", "plainText", "ampEmail", "mjml"],
|
|
||||||
limits: { ...DEFAULT_LIMITS, maxOutputBytes: 2_000_000 },
|
|
||||||
urlSchemes: ["https", "mailto"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const head = (document: BlockDocument, body: string, direction: "ltr" | "rtl") => {
|
|
||||||
const props = document.root.props as Record<string, unknown>;
|
|
||||||
const textColor = escapeHtml(props.textColor ?? "#111827");
|
|
||||||
const linkColor = escapeHtml(props.linkColor ?? "#2563eb");
|
|
||||||
const fontFamily = escapeHtml(props.fontFamily ?? "Arial, sans-serif");
|
|
||||||
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, String(document.root.props.linkColor ?? "#2563eb")) + 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 props = document.root.props as Record<string, unknown>;
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { renderEmailPreview } from "../src/browser.ts";
|
||||||
|
import { renderEmail } from "../src/email.ts";
|
||||||
|
import type { BlockDocument } from "@wrnexus/blocks";
|
||||||
|
|
||||||
|
const document: BlockDocument = {
|
||||||
|
schemaVersion: 1, kind: "email", meta: { subject: "Local preview", lang: "en" }, theme: { tokens: {} },
|
||||||
|
root: { id: "root", type: "layout.email-document", version: 1, props: { width: "600" }, styles: {}, slots: {
|
||||||
|
content: [{ id: "text", type: "content.text", version: 1, props: { text: "Hello {{recipientName|friend}}" }, styles: {}, slots: {} }],
|
||||||
|
} },
|
||||||
|
};
|
||||||
|
|
||||||
|
test("browser preview matches production HTML/text without server validators", async () => {
|
||||||
|
const before = structuredClone(document);
|
||||||
|
for (const direction of ["ltr", "rtl"] as const) {
|
||||||
|
const options = { direction, variables: { recipientName: "<script>unsafe</script>" } };
|
||||||
|
const preview = await renderEmailPreview(document, options);
|
||||||
|
const production = await renderEmail(document, options);
|
||||||
|
expect(preview.ok).toBe(true);
|
||||||
|
expect(production.ok).toBe(true);
|
||||||
|
if (preview.ok && production.ok) {
|
||||||
|
expect(preview.value.html).toBe(production.value.parts.html.content);
|
||||||
|
expect(preview.value.text).toBe(production.value.parts.text.content);
|
||||||
|
expect(preview.value.html).toContain("<script>");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(document).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("browser preview rejects unknown blocks without mutating them", async () => {
|
||||||
|
const invalid = structuredClone(document);
|
||||||
|
invalid.root.slots.content[0].type = "unknown.block";
|
||||||
|
const before = structuredClone(invalid);
|
||||||
|
expect((await renderEmailPreview(invalid)).ok).toBe(false);
|
||||||
|
expect(invalid).toEqual(before);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user