diff --git a/packages/blocks/package.json b/packages/blocks/package.json index a06777b1..287e1123 100644 --- a/packages/blocks/package.json +++ b/packages/blocks/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/blocks", - "version": "0.1.2", + "version": "0.1.3", "type": "module", "description": "Pure dynamic block document engine for WRNexus builders.", "exports": { diff --git a/packages/blocks/src/variables.ts b/packages/blocks/src/variables.ts index f70eb88f..3b803dfc 100644 --- a/packages/blocks/src/variables.ts +++ b/packages/blocks/src/variables.ts @@ -27,9 +27,11 @@ export function resolveDocumentVariables( document: BlockDocument, catalog: Record, data: Record = {}, - options: { requireValues?: boolean } = {}, + options: { requireValues?: boolean; preserveTokens?: boolean } = {}, ): Result { 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 => { if (typeof value === "string") 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 }); return raw; } + if (options.preserveTokens) return raw; const supplied = data[key]; if (supplied === undefined || supplied === null || supplied === "") { if (fallback !== undefined) return fallback.trim(); diff --git a/packages/email-renderer/package.json b/packages/email-renderer/package.json index 77114d5f..13a722ae 100644 --- a/packages/email-renderer/package.json +++ b/packages/email-renderer/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/email-renderer", - "version": "0.1.22", + "version": "0.1.23", "type": "module", "description": "WRNexus block email renderers with HTML, text, AMP Email, and MJML interoperability.", "exports": { diff --git a/packages/email-renderer/src/email.ts b/packages/email-renderer/src/email.ts index d25d61fb..bd3766ca 100644 --- a/packages/email-renderer/src/email.ts +++ b/packages/email-renderer/src/email.ts @@ -110,6 +110,8 @@ export async function renderEmail( direction?: "ltr" | "rtl"; variables?: Record; requireVariables?: boolean; + /** Preserve allowlisted tokens and fallbacks for template export, never for final delivery. */ + preserveVariables?: boolean; countdownImageUrl?: string; /** Trusted host injection; never sourced from the editable document. */ ampValidator?: (content: string) => Promise; @@ -118,6 +120,7 @@ export async function renderEmail( ): Promise> { const personalized = resolveDocumentVariables(document, emailKind.variables, options.variables, { requireValues: options.requireVariables, + preserveTokens: options.preserveVariables, }); if (!personalized.ok) return personalized; if (options.countdownImageUrl) { diff --git a/packages/email-renderer/test/template-variables.test.ts b/packages/email-renderer/test/template-variables.test.ts new file mode 100644 index 00000000..e12db3ac --- /dev/null +++ b/packages/email-renderer/test/template-variables.test.ts @@ -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); +});