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/blocks",
"version": "0.1.1",
"version": "0.1.2",
"type": "module",
"description": "Pure dynamic block document engine for WRNexus builders.",
"exports": {
+3
View File
@@ -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) => {
+1
View File
@@ -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";
+7 -1
View File
@@ -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);
+63
View File
@@ -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 };
}
+22 -1
View File
@@ -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);
});
});