feat(email-renderer): render countdown images across email targets
This commit is contained in:
1 parent
4df4173dfb
commit
d1f42e0566
4 files changed
+47
-4
No files matched your search
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/email-renderer",
|
"name": "@wrnexus/email-renderer",
|
||||||
"version": "0.1.10",
|
"version": "0.1.11",
|
||||||
"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": {
|
||||||
|
|||||||
@@ -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`,
|
({ 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(
|
export const countdownBlock = leaf(
|
||||||
"content.countdown",
|
"content.countdown",
|
||||||
"Countdown",
|
"Countdown",
|
||||||
@@ -411,9 +421,18 @@ export const countdownBlock = leaf(
|
|||||||
fallback: string({ max: 300, default: "This offer has ended." }),
|
fallback: string({ max: 300, default: "This offer has ended." }),
|
||||||
}) as never,
|
}) as never,
|
||||||
{
|
{
|
||||||
emailHtml: ({ node }) => emailCell(`<p style="font-family:inherit;font-size:20px;font-weight:700;margin:0 0 16px;">${html(attrs(node).label)}: <time datetime="${html(attrs(node).deadline)}">${html(attrs(node).deadline)}</time></p>`, node),
|
emailHtml: ({ node, context }) => {
|
||||||
ampEmail: ({ node }) => emailCell(`<p>${html(attrs(node).label)}: <time datetime="${html(attrs(node).deadline)}">${html(attrs(node).deadline)}</time></p>`, node),
|
const url = countdownUrl(node, context);
|
||||||
mjml: ({ node }) => `<mj-text font-size="20px" font-weight="700">${html(attrs(node).label)}: ${html(attrs(node).deadline)}</mj-text>`,
|
return emailCell(`<p style="font-family:inherit;font-size:20px;font-weight:700;margin:0 0 16px;">${html(attrs(node).label)}: <time datetime="${html(attrs(node).deadline)}">${html(attrs(node).deadline)}</time></p>${url ? `<img src="${url}" width="320" height="64" alt="${html(attrs(node).label)} — ${html(attrs(node).deadline)}" style="display:block;width:320px;max-width:100%;height:auto;" />` : ""}`, node);
|
||||||
|
},
|
||||||
|
ampEmail: ({ node, context }) => {
|
||||||
|
const url = countdownUrl(node, context);
|
||||||
|
return emailCell(`<p>${html(attrs(node).label)}: <time datetime="${html(attrs(node).deadline)}">${html(attrs(node).deadline)}</time></p>${url ? `<amp-img src="${url}" width="320" height="64" layout="responsive" alt="${html(attrs(node).label)}"></amp-img>` : ""}`, node);
|
||||||
|
},
|
||||||
|
mjml: ({ node, context }) => {
|
||||||
|
const url = countdownUrl(node, context);
|
||||||
|
return `<mj-text font-size="20px" font-weight="700">${html(attrs(node).label)}: ${html(attrs(node).deadline)}</mj-text>${url ? `<mj-image src="${url}" width="320px" alt="${html(attrs(node).label)}" />` : ""}`;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
({ node }) => `${String(attrs(node).label)}: ${String(attrs(node).deadline)}\n${String(attrs(node).fallback)}\n\n`,
|
({ node }) => `${String(attrs(node).label)}: ${String(attrs(node).deadline)}\n${String(attrs(node).fallback)}\n\n`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -106,18 +106,28 @@ export async function renderEmail(
|
|||||||
direction?: "ltr" | "rtl";
|
direction?: "ltr" | "rtl";
|
||||||
variables?: Record<string, unknown>;
|
variables?: Record<string, unknown>;
|
||||||
requireVariables?: boolean;
|
requireVariables?: boolean;
|
||||||
|
countdownImageUrl?: string;
|
||||||
} = {},
|
} = {},
|
||||||
): Promise<Result<EmailRenderBundle>> {
|
): Promise<Result<EmailRenderBundle>> {
|
||||||
const personalized = resolveDocumentVariables(document, emailKind.variables, options.variables, {
|
const personalized = resolveDocumentVariables(document, emailKind.variables, options.variables, {
|
||||||
requireValues: options.requireVariables,
|
requireValues: options.requireVariables,
|
||||||
});
|
});
|
||||||
if (!personalized.ok) return personalized;
|
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 source = personalized.value;
|
||||||
const registry = registryForKind(emailKind).freeze();
|
const registry = registryForKind(emailKind).freeze();
|
||||||
const base = {
|
const base = {
|
||||||
locale: options.locale ?? "en",
|
locale: options.locale ?? "en",
|
||||||
timezone: options.timezone ?? "UTC",
|
timezone: options.timezone ?? "UTC",
|
||||||
direction: options.direction ?? "ltr",
|
direction: options.direction ?? "ltr",
|
||||||
|
countdownImageUrl: options.countdownImageUrl,
|
||||||
};
|
};
|
||||||
const [htmlResult, textResult, mjmlResult] = await Promise.all([
|
const [htmlResult, textResult, mjmlResult] = await Promise.all([
|
||||||
renderTree(source, emailKind, registry, { ...base, target: "emailHtml", data: options.variables }),
|
renderTree(source, emailKind, registry, { ...base, target: "emailHtml", data: options.variables }),
|
||||||
|
|||||||
@@ -56,6 +56,20 @@ const document: BlockDocument = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe("email renderer", () => {
|
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 () => {
|
test("content inherits the selected brand font instead of overriding it", async () => {
|
||||||
const branded = structuredClone(document);
|
const branded = structuredClone(document);
|
||||||
branded.root.props.fontFamily = "Georgia, serif";
|
branded.root.props.fontFamily = "Georgia, serif";
|
||||||
|
|||||||
Reference in new issue
Block a user