From 157eec7a7885d9a0d979f3443679141ae145ace7 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Sat, 5 Sep 2026 11:23:02 +0530 Subject: [PATCH] feat(blocks): add typed personalization resolution --- packages/blocks/package.json | 2 +- packages/blocks/src/document.ts | 3 ++ packages/blocks/src/index.ts | 1 + packages/blocks/src/schema.ts | 8 ++- packages/blocks/src/variables.ts | 63 ++++++++++++++++++++++ packages/blocks/test/engine.test.ts | 23 +++++++- packages/email-renderer/package.json | 2 +- packages/email-renderer/src/email.ts | 24 ++++++--- packages/email-renderer/test/email.test.ts | 14 +++++ 9 files changed, 128 insertions(+), 12 deletions(-) create mode 100644 packages/blocks/src/variables.ts diff --git a/packages/blocks/package.json b/packages/blocks/package.json index e460ace7..a06777b1 100644 --- a/packages/blocks/package.json +++ b/packages/blocks/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/blocks", - "version": "0.1.1", + "version": "0.1.2", "type": "module", "description": "Pure dynamic block document engine for WRNexus builders.", "exports": { diff --git a/packages/blocks/src/document.ts b/packages/blocks/src/document.ts index 125294c9..9a1dbaec 100644 --- a/packages/blocks/src/document.ts +++ b/packages/blocks/src/document.ts @@ -1,5 +1,6 @@ import type { BlockRegistry, ReadonlyBlockRegistry } from "./registry.ts"; import type { DocumentKindDefinition } from "./kinds.ts"; +import { resolveDocumentVariables } from "./variables.ts"; import { DEFAULT_LIMITS, type BlockDocument, @@ -68,6 +69,8 @@ export function validateDocument( }); const meta = kind.metadata.parse(document.meta, "meta"); if (!meta.ok) errors.push(...meta.errors); + const variables = resolveDocumentVariables(document, kind.variables); + if (!variables.ok) errors.push(...variables.errors); const ids = new Set(); let count = 0; walkBlocks(document.root, (node, depth) => { diff --git a/packages/blocks/src/index.ts b/packages/blocks/src/index.ts index fef80eab..ebfb67b8 100644 --- a/packages/blocks/src/index.ts +++ b/packages/blocks/src/index.ts @@ -4,5 +4,6 @@ export * from "./registry.ts"; export * from "./kinds.ts"; export * from "./document.ts"; export * from "./commands.ts"; +export * from "./variables.ts"; export * from "./clipboard.ts"; export * from "./render.ts"; diff --git a/packages/blocks/src/schema.ts b/packages/blocks/src/schema.ts index a4f88d32..9e648749 100644 --- a/packages/blocks/src/schema.ts +++ b/packages/blocks/src/schema.ts @@ -92,8 +92,14 @@ export function safeUrl( if ((value === undefined || value === "") && options.optional) return { ok: true, value: "" }; if (typeof value !== "string" || value.length > 4_096) return fail("schema.url", "Expected a bounded URL.", path); + const token = /^\{\{\s*[A-Za-z][A-Za-z0-9_.-]*(?:\|[^{}]*)?\s*\}\}$/; + if (token.test(value)) return { ok: true, value }; try { - const parsed = new URL(value); + const templated = value.replace( + /\{\{\s*[A-Za-z][A-Za-z0-9_.-]*(?:\|[^{}]*)?\s*\}\}/g, + "variable", + ); + const parsed = new URL(templated); return schemes.has(parsed.protocol.replace(/:$/, "")) ? { ok: true, value: parsed.href } : fail("schema.url.scheme", `URL scheme ${parsed.protocol} is not allowed.`, path); diff --git a/packages/blocks/src/variables.ts b/packages/blocks/src/variables.ts new file mode 100644 index 00000000..f70eb88f --- /dev/null +++ b/packages/blocks/src/variables.ts @@ -0,0 +1,63 @@ +import type { BlockDocument, Diagnostic, JsonValue, Result } from "./types.ts"; + +export interface VariableToken { + raw: string; + key: string; + fallback?: string; +} + +const TOKEN = /\{\{\s*([A-Za-z][A-Za-z0-9_.-]*)(?:\|([^{}]*))?\s*\}\}/g; + +export function variableTokens(value: string): VariableToken[] { + return [...value.matchAll(TOKEN)].map((match) => ({ + raw: match[0], + key: match[1]!, + fallback: match[2]?.trim() || undefined, + })); +} + +const validType = (value: unknown, type: string) => { + if (type === "string") return typeof value === "string"; + if (type === "number") return typeof value === "number" && Number.isFinite(value); + if (type === "boolean") return typeof value === "boolean"; + return type === "date" && (value instanceof Date || !Number.isNaN(Date.parse(String(value)))); +}; + +export function resolveDocumentVariables( + document: BlockDocument, + catalog: Record, + data: Record = {}, + options: { requireValues?: boolean } = {}, +): Result { + const diagnostics: Diagnostic[] = []; + const visit = (value: JsonValue, path: string): JsonValue => { + if (typeof value === "string") + return value.replace(TOKEN, (raw, key: string, fallback: string | undefined) => { + const type = catalog[key]; + if (!type) { + diagnostics.push({ code: "variable.unknown", message: `Unknown variable ${key}.`, severity: "error", path }); + return raw; + } + const supplied = data[key]; + if (supplied === undefined || supplied === null || supplied === "") { + if (fallback !== undefined) return fallback.trim(); + if (options.requireValues) + diagnostics.push({ code: "variable.missing", message: `Variable ${key} is required.`, severity: "error", path }); + return raw; + } + if (!validType(supplied, type)) { + diagnostics.push({ code: "variable.type", message: `Variable ${key} must be ${type}.`, severity: "error", path }); + return raw; + } + return supplied instanceof Date ? supplied.toISOString() : String(supplied); + }); + if (Array.isArray(value)) return value.map((item, index) => visit(item, `${path}.${index}`)); + if (value && typeof value === "object") + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [key, visit(item, `${path}.${key}`)]), + ); + return value; + }; + const resolved = visit(structuredClone(document) as unknown as JsonValue, "document") as unknown as BlockDocument; + return diagnostics.length ? { ok: false, errors: diagnostics } : { ok: true, value: resolved }; +} diff --git a/packages/blocks/test/engine.test.ts b/packages/blocks/test/engine.test.ts index 157e74e5..570aed97 100644 --- a/packages/blocks/test/engine.test.ts +++ b/packages/blocks/test/engine.test.ts @@ -10,6 +10,8 @@ import { json, object, record, + resolveDocumentVariables, + safeUrl, string, validateDocument, type BlockDocument, @@ -69,7 +71,7 @@ const kind = defineDocumentKind({ metadata: object({ title: string() }) as never, rootTypes: ["layout.root"], blocks: [root, text], - variables: {}, + variables: { recipientName: "string" }, breakpoints: {}, capabilities: [], targets: ["html"], @@ -169,4 +171,23 @@ describe("dynamic blocks engine", () => { "a", "a-copy", ]); }); + + test("validates and resolves typed personalization tokens", () => { + const document = doc(); + document.root.slots.content = [ + { ...text.create({ id: () => "personalized" }), props: { text: "Hi {{recipientName}}" } }, + ]; + expect(validateDocument(document, kind, registry).ok).toBe(true); + const resolved = resolveDocumentVariables(document, kind.variables, { recipientName: "Ada" }); + expect(resolved.ok && resolved.value.root.slots.content[0]?.props.text).toBe("Hi Ada"); + document.root.slots.content[0]!.props.text = "{{notAllowed}}"; + expect(validateDocument(document, kind, registry).ok).toBe(false); + }); + + test("safe URLs allow bounded variable templates without allowing unsafe schemes", () => { + const schema = safeUrl({ schemes: ["https"] }); + expect(schema.parse("https://example.com/u/{{recipientName}}").ok).toBe(true); + expect(schema.parse("{{unsubscribeUrl}}").ok).toBe(true); + expect(schema.parse("javascript:alert(1)").ok).toBe(false); + }); }); diff --git a/packages/email-renderer/package.json b/packages/email-renderer/package.json index 7688d8b1..cb9f6494 100644 --- a/packages/email-renderer/package.json +++ b/packages/email-renderer/package.json @@ -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": { diff --git a/packages/email-renderer/src/email.ts b/packages/email-renderer/src/email.ts index ccdfe4a2..6632b6cb 100644 --- a/packages/email-renderer/src/email.ts +++ b/packages/email-renderer/src/email.ts @@ -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; + requireVariables?: boolean; } = {}, ): Promise> { + 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 }; diff --git a/packages/email-renderer/test/email.test.ts b/packages/email-renderer/test/email.test.ts index 539cff7c..0a6037e3 100644 --- a/packages/email-renderer/test/email.test.ts +++ b/packages/email-renderer/test/email.test.ts @@ -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("x", {