feat(blocks): add typed personalization resolution
Quality / quality (ubuntu-latest) (push) Failing after 9m58s
Quality / quality (windows-latest) (push) Canceled after 0s

This commit is contained in:
Clintchiz committed 2026-09-05 11:23:02 +05:30
1 parent 2428df6e78
commit 157eec7a78
9 files changed
+128 -12

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/email-renderer",
"version": "0.1.3",
"version": "0.1.4",
"type": "module",
"description": "WRNexus block email renderers with HTML, text, AMP Email, and MJML interoperability.",
"exports": {
+16 -8
View File
@@ -5,6 +5,7 @@ import {
escapeHtml,
object,
registryForKind,
resolveDocumentVariables,
renderTree,
string,
type BlockDocument,
@@ -103,8 +104,15 @@ export async function renderEmail(
locale?: string;
timezone?: string;
direction?: "ltr" | "rtl";
variables?: Record<string, unknown>;
requireVariables?: boolean;
} = {},
): Promise<Result<EmailRenderBundle>> {
const personalized = resolveDocumentVariables(document, emailKind.variables, options.variables, {
requireValues: options.requireVariables,
});
if (!personalized.ok) return personalized;
const source = personalized.value;
const registry = registryForKind(emailKind).freeze();
const base = {
locale: options.locale ?? "en",
@@ -112,10 +120,10 @@ export async function renderEmail(
direction: options.direction ?? "ltr",
};
const [htmlResult, textResult, mjmlResult] = await Promise.all([
renderTree(document, emailKind, registry, { ...base, target: "emailHtml" }),
renderTree(document, emailKind, registry, { ...base, target: "plainText" }),
renderTree(source, emailKind, registry, { ...base, target: "emailHtml", data: options.variables }),
renderTree(source, emailKind, registry, { ...base, target: "plainText", data: options.variables }),
options.mjml
? renderTree(document, emailKind, registry, { ...base, target: "mjml" })
? renderTree(source, emailKind, registry, { ...base, target: "mjml", data: options.variables })
: Promise.resolve(undefined),
]);
if (!htmlResult.ok) return htmlResult;
@@ -123,8 +131,8 @@ export async function renderEmail(
const warnings: Diagnostic[] = [...htmlResult.value.warnings, ...textResult.value.warnings];
const bundle: EmailRenderBundle = {
kind: "email",
subject: String(document.meta.subject),
preheader: String(document.meta.preheader || "") || undefined,
subject: String(source.meta.subject),
preheader: String(source.meta.preheader || "") || undefined,
parts: {
text: {
contentType: "text/plain; charset=utf-8",
@@ -132,7 +140,7 @@ export async function renderEmail(
},
html: {
contentType: "text/html; charset=utf-8",
content: head(document, htmlResult.value.content, base.direction),
content: head(source, htmlResult.value.content, base.direction),
},
},
warnings,
@@ -140,9 +148,9 @@ export async function renderEmail(
};
if (mjmlResult?.ok) bundle.exports = { mjml: mjmlResult.value.content };
if (options.amp) {
const amp = await renderTree(document, emailKind, registry, { ...base, target: "ampEmail" });
const amp = await renderTree(source, emailKind, registry, { ...base, target: "ampEmail", data: options.variables });
if (amp.ok) {
const content = ampHead(document, amp.value.content, base.direction);
const content = ampHead(source, amp.value.content, base.direction);
const validation = await validateAmpEmail(content);
if (validation.valid)
bundle.parts.amp = { contentType: "text/x-amp-html; charset=utf-8", content, validation };
@@ -90,6 +90,20 @@ describe("email renderer", () => {
expect(rendered.value.parts.html.content).not.toContain("position:fixed");
});
test("resolves allowlisted personalization values and fallbacks", async () => {
const personalized = structuredClone(document);
personalized.root.slots.content[0]!.slots.content[0]!.props.text =
"Hello {{recipientName|friend}}";
const rendered = await renderEmail(personalized, { variables: { recipientName: "Ada" } });
expect(rendered.ok && rendered.value.parts.html.content).toContain("Hello Ada");
const fallback = await renderEmail(personalized);
expect(fallback.ok && fallback.value.parts.text.content).toContain("Hello friend");
const strict = await renderEmail(personalized, { requireVariables: true });
expect(strict.ok).toBe(true);
personalized.root.slots.content[0]!.slots.content[0]!.props.text = "Hello {{recipientName}}";
expect((await renderEmail(personalized, { requireVariables: true })).ok).toBe(false);
});
test("rejects unsafe MJML and imports supported content", () => {
expect(
importMjml("<mjml><mj-body><mj-raw>x</mj-raw></mj-body></mjml>", {