From d1f42e0566d6a5447c54c0d2f62a8e9d9b49044c Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Sat, 5 Sep 2026 12:27:39 +0530 Subject: [PATCH] feat(email-renderer): render countdown images across email targets --- packages/email-renderer/package.json | 2 +- packages/email-renderer/src/blocks.ts | 25 +++++++++++++++++++--- packages/email-renderer/src/email.ts | 10 +++++++++ packages/email-renderer/test/email.test.ts | 14 ++++++++++++ 4 files changed, 47 insertions(+), 4 deletions(-) diff --git a/packages/email-renderer/package.json b/packages/email-renderer/package.json index ae201fab..f261e31f 100644 --- a/packages/email-renderer/package.json +++ b/packages/email-renderer/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/email-renderer", - "version": "0.1.10", + "version": "0.1.11", "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 5767e213..7a46db6b 100644 --- a/packages/email-renderer/src/blocks.ts +++ b/packages/email-renderer/src/blocks.ts @@ -401,6 +401,16 @@ export const socialBlock = leaf( ({ node }) => `${String(attrs(node).label)}: ${[["Facebook",attrs(node).facebook],["Instagram",attrs(node).instagram],["LinkedIn",attrs(node).linkedin],["X",attrs(node).x]].filter(([,url])=>url).map(([name,url])=>`${name}: ${url}`).join(" · ")}\n\n`, ); +const countdownUrl = (node: BlockNode, context: unknown): string | undefined => { + const timestamp = Date.parse(String(attrs(node).deadline)); + if (!Number.isFinite(timestamp)) throw new Error("Countdown deadline must be a valid date."); + const endpoint = (context as { countdownImageUrl?: string })?.countdownImageUrl; + if (!endpoint) return undefined; + const url = new URL(endpoint); + url.searchParams.set("deadline", String(timestamp)); + return html(url.href); +}; + export const countdownBlock = leaf( "content.countdown", "Countdown", @@ -411,9 +421,18 @@ export const countdownBlock = leaf( fallback: string({ max: 300, default: "This offer has ended." }), }) as never, { - emailHtml: ({ node }) => emailCell(`

${html(attrs(node).label)}:

`, node), - ampEmail: ({ node }) => emailCell(`

${html(attrs(node).label)}:

`, node), - mjml: ({ node }) => `${html(attrs(node).label)}: ${html(attrs(node).deadline)}`, + emailHtml: ({ node, context }) => { + const url = countdownUrl(node, context); + return emailCell(`

${html(attrs(node).label)}:

${url ? `${html(attrs(node).label)} — ${html(attrs(node).deadline)}` : ""}`, node); + }, + ampEmail: ({ node, context }) => { + const url = countdownUrl(node, context); + return emailCell(`

${html(attrs(node).label)}:

${url ? `` : ""}`, node); + }, + mjml: ({ node, context }) => { + const url = countdownUrl(node, context); + return `${html(attrs(node).label)}: ${html(attrs(node).deadline)}${url ? `` : ""}`; + }, }, ({ node }) => `${String(attrs(node).label)}: ${String(attrs(node).deadline)}\n${String(attrs(node).fallback)}\n\n`, ); diff --git a/packages/email-renderer/src/email.ts b/packages/email-renderer/src/email.ts index 6632b6cb..15abd616 100644 --- a/packages/email-renderer/src/email.ts +++ b/packages/email-renderer/src/email.ts @@ -106,18 +106,28 @@ export async function renderEmail( direction?: "ltr" | "rtl"; variables?: Record; requireVariables?: boolean; + countdownImageUrl?: string; } = {}, ): Promise> { const personalized = resolveDocumentVariables(document, emailKind.variables, options.variables, { requireValues: options.requireVariables, }); if (!personalized.ok) return personalized; + if (options.countdownImageUrl) { + try { + const url = new URL(options.countdownImageUrl); + if (url.username || url.password || (url.protocol !== "https:" && !(url.protocol === "http:" && (url.hostname === "localhost" || url.hostname.endsWith(".localhost"))))) throw new Error(); + } catch { + return { ok: false, errors: [{ code: "countdown.endpoint", message: "Countdown images require HTTPS (or a local development host).", severity: "error" }] }; + } + } const source = personalized.value; const registry = registryForKind(emailKind).freeze(); const base = { locale: options.locale ?? "en", timezone: options.timezone ?? "UTC", direction: options.direction ?? "ltr", + countdownImageUrl: options.countdownImageUrl, }; const [htmlResult, textResult, mjmlResult] = await Promise.all([ renderTree(source, emailKind, registry, { ...base, target: "emailHtml", data: options.variables }), diff --git a/packages/email-renderer/test/email.test.ts b/packages/email-renderer/test/email.test.ts index f4955725..9718ecac 100644 --- a/packages/email-renderer/test/email.test.ts +++ b/packages/email-renderer/test/email.test.ts @@ -56,6 +56,20 @@ const document: BlockDocument = { }; describe("email renderer", () => { + test("countdown URLs are derived for HTML AMP and MJML without mutating the document", async () => { + const draft = structuredClone(document); + draft.root.slots.content[0]!.slots.content = [{id:"timer",type:"content.countdown",version:1,props:{label:"Offer ends",deadline:"2026-10-01T00:00:00Z",fallback:"Offer ended"},styles:{},slots:{}}]; + const before = JSON.stringify(draft); + const result = await renderEmail(draft, {amp:true,mjml:true,countdownImageUrl:"https://email.example.com/api/countdown"}); + expect(result.ok).toBe(true); + if (!result.ok) return; + const image = "https://email.example.com/api/countdown?deadline=1790812800000"; + expect(result.value.parts.html.content).toContain(image); + expect(result.value.parts.amp?.content).toContain(image); + expect(result.value.exports?.mjml).toContain(image); + expect(JSON.stringify(draft)).toBe(before); + expect((await renderEmail(draft, {countdownImageUrl:"javascript:alert(1)"})).ok).toBe(false); + }); test("content inherits the selected brand font instead of overriding it", async () => { const branded = structuredClone(document); branded.root.props.fontFamily = "Georgia, serif";