diff --git a/packages/email-renderer/package.json b/packages/email-renderer/package.json index c0810f84..326df046 100644 --- a/packages/email-renderer/package.json +++ b/packages/email-renderer/package.json @@ -1,11 +1,12 @@ { "name": "@wrnexus/email-renderer", - "version": "0.1.40", + "version": "0.1.41", "type": "module", "description": "WRNexus block email renderers with HTML, text, AMP Email, and MJML interoperability.", "exports": { ".": "./src/index.ts", - "./blocks": "./src/blocks.ts" + "./blocks": "./src/blocks.ts", + "./create": "./src/create.ts" }, "files": [ "src", diff --git a/packages/email-renderer/src/create.ts b/packages/email-renderer/src/create.ts new file mode 100644 index 00000000..a52b3e83 --- /dev/null +++ b/packages/email-renderer/src/create.ts @@ -0,0 +1,15 @@ +import type { BlockNode } from "@wrnexus/blocks"; +import { emailCoreBlocks } from "./blocks.ts"; + +/** Canonical initial content for palette blocks; time and instance IDs are injectable. */ +export function createEmailBlock(type: string, context: { id(): string; now?: number }): BlockNode { + const now = context.now ?? Date.now(); + const defaults: Record> = {"content.heading":{text:'New heading',level:'2'},"content.text":{text:'New paragraph'},"content.button":{label:'Call to action',href:'https://example.com'},"content.image":{src:'https://placehold.co/600x300',alt:'',width:'600',height:'300',decorative:false},"content.divider":{},"content.spacer":{height:'24'},"content.video":{title:'Watch video',href:'https://example.com',thumbnail:'https://placehold.co/600x338',alt:'Video thumbnail',width:'600',height:'338'},"content.social":{label:'Follow us',facebook:'',instagram:'',linkedin:'',x:''},"content.countdown":{label:'Offer ends',deadline:new Date(now+86400000).toISOString(),fallback:'This offer has ended.'},"content.survey":{question:'What do you think?',buttonLabel:'Answer survey',href:'https://example.com'},"content.feed-item":{width:'600',height:'400',feedUrl:'',title:'Latest article',summary:'Article summary',href:'https://example.com',image:''},"commerce.product":{width:'600',height:'400',provider:'manual',storeUrl:'',productId:'',title:'Featured product',price:'$0.00',image:'https://placehold.co/600x400',href:'https://example.com',buttonLabel:'Shop now'}}; + const definition = emailCoreBlocks.find(block => block.manifest.type === type); + if (!definition || !Object.prototype.hasOwnProperty.call(defaults, type)) + throw new Error("This block is not available for palette insertion."); + const props = definition.manifest.props.parse(defaults[type], "props"); + if (!props.ok) throw new Error(props.errors.map(error => error.message).join("\n")); + const node = definition.create(context); + return { ...node, type: definition.manifest.type, version: definition.manifest.version, props: props.value }; +} diff --git a/packages/email-renderer/test/create.test.ts b/packages/email-renderer/test/create.test.ts new file mode 100644 index 00000000..a737db47 --- /dev/null +++ b/packages/email-renderer/test/create.test.ts @@ -0,0 +1,18 @@ +import { expect, test } from "bun:test"; +import { createEmailBlock } from "../src/create.ts"; +import { emailCoreBlocks } from "../src/blocks.ts"; + +test("palette factory produces schema-valid independent blocks with injected IDs and time", () => { + let sequence = 0; + for (const definition of emailCoreBlocks.filter(block => /^(content|commerce)\./.test(block.manifest.type))) { + const context = { id: () => `node-${++sequence}`, now: Date.parse("2026-09-05T00:00:00Z") }; + const first = createEmailBlock(definition.manifest.type, context), second = createEmailBlock(definition.manifest.type, context); + expect(definition.manifest.props.parse(first.props).ok).toBe(true); + expect(first.id).not.toBe(second.id); + expect(first.props).toEqual(second.props); + expect(first.props).not.toBe(second.props); + expect(first.version).toBe(definition.manifest.version); + if (first.type === "content.countdown") expect(first.props.deadline).toBe("2026-09-06T00:00:00.000Z"); + } + expect(() => createEmailBlock("untrusted.block", { id: () => "id" })).toThrow(); +});