fix(email): preserve personalization tokens for template rendering
This commit is contained in:
1 parent
8bc8fe85c3
commit
e1ea6e2977
5 files changed
+41
-3
No files matched your search
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/blocks",
|
"name": "@wrnexus/blocks",
|
||||||
"version": "0.1.2",
|
"version": "0.1.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Pure dynamic block document engine for WRNexus builders.",
|
"description": "Pure dynamic block document engine for WRNexus builders.",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -27,9 +27,11 @@ export function resolveDocumentVariables(
|
|||||||
document: BlockDocument,
|
document: BlockDocument,
|
||||||
catalog: Record<string, "string" | "number" | "date" | "boolean">,
|
catalog: Record<string, "string" | "number" | "date" | "boolean">,
|
||||||
data: Record<string, unknown> = {},
|
data: Record<string, unknown> = {},
|
||||||
options: { requireValues?: boolean } = {},
|
options: { requireValues?: boolean; preserveTokens?: boolean } = {},
|
||||||
): Result<BlockDocument> {
|
): Result<BlockDocument> {
|
||||||
const diagnostics: Diagnostic[] = [];
|
const diagnostics: Diagnostic[] = [];
|
||||||
|
if (options.preserveTokens && options.requireValues)
|
||||||
|
return { ok: false, errors: [{ code: "variable.mode", message: "Template tokens cannot be preserved when resolved values are required.", severity: "error" }] };
|
||||||
const visit = (value: JsonValue, path: string): JsonValue => {
|
const visit = (value: JsonValue, path: string): JsonValue => {
|
||||||
if (typeof value === "string")
|
if (typeof value === "string")
|
||||||
return value.replace(TOKEN, (raw, key: string, fallback: string | undefined) => {
|
return value.replace(TOKEN, (raw, key: string, fallback: string | undefined) => {
|
||||||
@@ -38,6 +40,7 @@ export function resolveDocumentVariables(
|
|||||||
diagnostics.push({ code: "variable.unknown", message: `Unknown variable ${key}.`, severity: "error", path });
|
diagnostics.push({ code: "variable.unknown", message: `Unknown variable ${key}.`, severity: "error", path });
|
||||||
return raw;
|
return raw;
|
||||||
}
|
}
|
||||||
|
if (options.preserveTokens) return raw;
|
||||||
const supplied = data[key];
|
const supplied = data[key];
|
||||||
if (supplied === undefined || supplied === null || supplied === "") {
|
if (supplied === undefined || supplied === null || supplied === "") {
|
||||||
if (fallback !== undefined) return fallback.trim();
|
if (fallback !== undefined) return fallback.trim();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/email-renderer",
|
"name": "@wrnexus/email-renderer",
|
||||||
"version": "0.1.22",
|
"version": "0.1.23",
|
||||||
"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": {
|
||||||
|
|||||||
@@ -110,6 +110,8 @@ export async function renderEmail(
|
|||||||
direction?: "ltr" | "rtl";
|
direction?: "ltr" | "rtl";
|
||||||
variables?: Record<string, unknown>;
|
variables?: Record<string, unknown>;
|
||||||
requireVariables?: boolean;
|
requireVariables?: boolean;
|
||||||
|
/** Preserve allowlisted tokens and fallbacks for template export, never for final delivery. */
|
||||||
|
preserveVariables?: boolean;
|
||||||
countdownImageUrl?: string;
|
countdownImageUrl?: string;
|
||||||
/** Trusted host injection; never sourced from the editable document. */
|
/** Trusted host injection; never sourced from the editable document. */
|
||||||
ampValidator?: (content: string) => Promise<ValidationReport>;
|
ampValidator?: (content: string) => Promise<ValidationReport>;
|
||||||
@@ -118,6 +120,7 @@ export async function renderEmail(
|
|||||||
): 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,
|
||||||
|
preserveTokens: options.preserveVariables,
|
||||||
});
|
});
|
||||||
if (!personalized.ok) return personalized;
|
if (!personalized.ok) return personalized;
|
||||||
if (options.countdownImageUrl) {
|
if (options.countdownImageUrl) {
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { resolveDocumentVariables, type BlockDocument } from "@wrnexus/blocks";
|
||||||
|
import { renderEmail } from "../src/index.ts";
|
||||||
|
|
||||||
|
const document: BlockDocument = {
|
||||||
|
schemaVersion: 1, kind: "email", meta: { subject: "Hi {{recipientName|friend}}", lang: "en" },
|
||||||
|
theme: { tokens: {} },
|
||||||
|
root: { id: "root", type: "layout.email-document", version: 1, props: {}, styles: {}, slots: {
|
||||||
|
content: [{ id: "text", type: "content.text", version: 1, props: { text: "Hello {{recipientName|friend}}" }, styles: {}, slots: {} }],
|
||||||
|
} },
|
||||||
|
};
|
||||||
|
|
||||||
|
test("template rendering preserves fallback tokens rather than baking in sample or fallback values", async () => {
|
||||||
|
const before = structuredClone(document);
|
||||||
|
const rendered = await renderEmail(document, { preserveVariables: true, mjml: true, variables: { recipientName: "Preview only" } });
|
||||||
|
expect(rendered.ok).toBe(true);
|
||||||
|
if (!rendered.ok) return;
|
||||||
|
expect(rendered.value.subject).toBe("Hi {{recipientName|friend}}");
|
||||||
|
expect(rendered.value.parts.html.content).toContain("Hello {{recipientName|friend}}");
|
||||||
|
expect(rendered.value.parts.text.content).toContain("Hello {{recipientName|friend}}");
|
||||||
|
expect(rendered.value.exports?.mjml).toContain("Hello {{recipientName|friend}}");
|
||||||
|
expect(document).toEqual(before);
|
||||||
|
const resolved = await renderEmail(document, { variables: { recipientName: "Ada" }, requireVariables: true });
|
||||||
|
expect(resolved.ok && resolved.value.subject).toBe("Hi Ada");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("preservation still validates token allowlists and cannot masquerade as resolved delivery", () => {
|
||||||
|
const unknown = structuredClone(document);
|
||||||
|
unknown.meta.subject = "{{unregistered}}";
|
||||||
|
expect(resolveDocumentVariables(unknown, { recipientName: "string" }, {}, { preserveTokens: true }).ok).toBe(false);
|
||||||
|
expect(resolveDocumentVariables(document, { recipientName: "string" }, {}, { preserveTokens: true, requireValues: true }).ok).toBe(false);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user