feat(blocks): add typed personalization resolution
This commit is contained in:
1 parent
2428df6e78
commit
157eec7a78
9 files changed
+128
-12
No files matched your search
@@ -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": {
|
||||
|
||||
@@ -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<string>();
|
||||
let count = 0;
|
||||
walkBlocks(document.root, (node, depth) => {
|
||||
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string, "string" | "number" | "date" | "boolean">,
|
||||
data: Record<string, unknown> = {},
|
||||
options: { requireValues?: boolean } = {},
|
||||
): Result<BlockDocument> {
|
||||
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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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": {
|
||||
|
||||
@@ -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>", {
|
||||
|
||||
Reference in new issue
Block a user