diff --git a/packages/email-renderer/package.json b/packages/email-renderer/package.json index 7210be0c..38ae52f0 100644 --- a/packages/email-renderer/package.json +++ b/packages/email-renderer/package.json @@ -1,11 +1,12 @@ { "name": "@wrnexus/email-renderer", - "version": "0.1.43", + "version": "0.1.44", "type": "module", "description": "WRNexus block email renderers with HTML, text, AMP Email, and MJML interoperability.", "exports": { ".": "./src/index.ts", "./blocks": "./src/blocks.ts", + "./browser": "./src/browser.ts", "./create": "./src/create.ts", "./style-values": "./src/style-values.ts" }, diff --git a/packages/email-renderer/src/browser.ts b/packages/email-renderer/src/browser.ts new file mode 100644 index 00000000..0ab44ad8 --- /dev/null +++ b/packages/email-renderer/src/browser.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; + const textColor = escapeHtml(props.textColor ?? "#111827"); + const linkColor = escapeHtml(props.linkColor ?? "#2563eb"); + const fontFamily = escapeHtml(props.fontFamily ?? "Arial, sans-serif"); + return `${escapeHtml(document.meta.subject)}${document.meta.preheader ? `
${escapeHtml(document.meta.preheader)}
` : ""}${body}`; +}; + +export async function renderEmailPreview(document: BlockDocument, options: { + variables?: Record; + 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", + } }; +} diff --git a/packages/email-renderer/src/email.ts b/packages/email-renderer/src/email.ts index a08f78a8..89c093da 100644 --- a/packages/email-renderer/src/email.ts +++ b/packages/email-renderer/src/email.ts @@ -55,31 +55,8 @@ export interface EmailRenderBundle { assets: string[]; } -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"], -}); - -const head = (document: BlockDocument, body: string, direction: "ltr" | "rtl") => { - const props = document.root.props as Record; - const textColor = escapeHtml(props.textColor ?? "#111827"); - const linkColor = escapeHtml(props.linkColor ?? "#2563eb"); - const fontFamily = escapeHtml(props.fontFamily ?? "Arial, sans-serif"); - return `${escapeHtml(document.meta.subject)}${document.meta.preheader ? `
${escapeHtml(document.meta.preheader)}
` : ""}${body}`; -}; +export { emailKind } from "./browser.ts"; +import { emailKind, emailHtmlHead as head } from "./browser.ts"; 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 props = document.root.props as Record; diff --git a/packages/email-renderer/test/browser.test.ts b/packages/email-renderer/test/browser.test.ts new file mode 100644 index 00000000..3d57163b --- /dev/null +++ b/packages/email-renderer/test/browser.test.ts @@ -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: "" } }; + 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); +});