import { boolean, choice, defineBlock, json, object, record, safeUrl, string, type BlockDefinition, type BlockNode, type ValueSchema, } from "@wrnexus/blocks"; import { brandColor, brandFont, brandWidth } from "./brand.ts"; const styles = record(json, { maxKeys: 50 }) as never; const label = (key: string, fallback: string) => ({ key, fallback }); const empty = object({}) as never; const contentSlot = { label: label("blocks.slot.content", "Content"), accepts: { capabilities: ["content"] }, maxChildren: 500, orientation: "vertical" as const, sortable: true, }; const html = (value: unknown) => String(value ?? "") .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); const attrs = (node: BlockNode) => node.props as Record; const isEmailRootChild = (node: BlockNode, context: unknown) => (context as { rootBlockIds?: string[] } | undefined)?.rootBlockIds?.includes(node.id) ?? false; const mjmlDocumentHead = (node: BlockNode, context: unknown) => { const meta = context as { emailSubject?: string; emailPreheader?: string } | undefined; return `${html(meta?.emailSubject)}${meta?.emailPreheader ? `${html(meta.emailPreheader)}` : ""}`; }; export const ampLayoutClass = (node: BlockNode) => "wrn-layout-" + Array.from(node.id, char => char.codePointAt(0)!.toString(16)).join("-"); export function ampLayoutCss(node: BlockNode, brandLinkColor?: string): string { const props = attrs(node); if (node.type === "content.button") return `.button.${ampLayoutClass(node)}{background-color:${buttonBackground(node, { brandLinkColor })};${buttonVisualCss(node)}}`; if (node.type === "layout.section") { const background = safeCssValue(props.background, /^(#[0-9a-f]{3,8}|rgba?\([0-9., %]+\))$/i) || "#ffffff"; const padding = safeCssValue(props.padding, /^\d+(?:\.\d+)?(?:px|rem|em|%)?(?:\s+\d+(?:\.\d+)?(?:px|rem|em|%)?){0,3}$/) || "24px"; return `.${ampLayoutClass(node)}{background:${background};padding:${padding}}`; } if (node.type === "content.spacer") { const height = safeCssValue(props.height, /^\d{1,4}$/) || "24"; return `.${ampLayoutClass(node)}{height:${height}px;line-height:${height}px;font-size:1px}`; } return ""; } const multilineText = (value: unknown) => html(value).replace(/\r\n?|\n/g, "
"); const imageDimension = (fallback: string, minimum = 1): ValueSchema => ({ kind: "string", defaultValue: fallback, parse(value = fallback, path = "value") { return typeof value === "string" && /^\d{1,4}$/.test(value) && Number(value) >= minimum && Number(value) <= 4000 ? { ok: true, value } : { ok: false, errors: [{code:"email.image-dimension",message:`Dimensions must be between ${minimum} and 4000 pixels.`,severity:"error",path}] }; }, }); const marketingImage = (node: BlockNode, target: "html" | "amp" | "mjml") => { const p = attrs(node); if (!p.image) return ""; const width = html(p.width ?? "600"), height = html(p.height ?? "400"); const src = html(p.image), alt = html(p.title); if (target === "amp") return ``; if (target === "mjml") return ``; return `${alt}`; }; const styleBase = (node: BlockNode, key: string) => { const value = (node.styles as Record)[key]; return value && typeof value === "object" && !Array.isArray(value) ? (value as Record).base : undefined; }; const safeCssValue = (value: unknown, pattern: RegExp) => { const candidate = String(value ?? "").trim(); return candidate.length <= 80 && pattern.test(candidate) ? candidate : ""; }; const semanticStyle = (node: BlockNode) => { const declarations: string[] = []; const color = safeCssValue(styleBase(node, "color"), /^(#[0-9a-f]{3,8}|rgba?\([0-9., %]+\))$/i); const background = safeCssValue(styleBase(node, "backgroundColor"), /^(#[0-9a-f]{3,8}|rgba?\([0-9., %]+\))$/i); const padding = safeCssValue(styleBase(node, "padding"), /^\d+(?:\.\d+)?(?:px|rem|em|%)?(?:\s+\d+(?:\.\d+)?(?:px|rem|em|%)?){0,3}$/); const margin = safeCssValue(styleBase(node, "margin"), /^\d+(?:\.\d+)?(?:px|rem|em|%)?(?:\s+\d+(?:\.\d+)?(?:px|rem|em|%)?){0,3}$/); const fontSize = safeCssValue(styleBase(node, "fontSize"), /^\d+(?:\.\d+)?(?:px|rem|em|%)$/); const radius = safeCssValue(styleBase(node, "borderRadius"), /^\d+(?:\.\d+)?(?:px|rem|em|%)$/); const align = safeCssValue(styleBase(node, "textAlign"), /^(left|center|right|start|end)$/); const weight = safeCssValue(styleBase(node, "fontWeight"), /^(normal|bold|[1-9]00)$/); if (color) declarations.push(`color:${color}`); if (background) declarations.push(`background-color:${background}`); if (padding) declarations.push(`padding:${padding}`); if (margin) declarations.push(`margin:${margin}`); if (fontSize) declarations.push(`font-size:${fontSize}`); if (radius) declarations.push(`border-radius:${radius}`); if (align) declarations.push(`text-align:${align}`); if (weight) declarations.push(`font-weight:${weight}`); return declarations.join(";"); }; const buttonBackground = (node: BlockNode, context: unknown) => { const override = safeCssValue(styleBase(node, "backgroundColor"), /^(#[0-9a-f]{3,8}|rgba?\([0-9., %]+\))$/i); const brand = (context as { brandLinkColor?: string } | undefined)?.brandLinkColor; return override || (brand && brandColor("#2563eb").parse(brand).ok ? brand : "#2563eb"); }; const buttonVisuals = (node: BlockNode) => ({ color: safeCssValue(styleBase(node, "color"), /^(#[0-9a-f]{3,8}|rgba?\([0-9., %]+\))$/i) || "#ffffff", fontSize: safeCssValue(styleBase(node, "fontSize"), /^\d+(?:\.\d+)?(?:px|rem|em|%)$/) || "16px", fontWeight: safeCssValue(styleBase(node, "fontWeight"), /^(normal|bold|[1-9]00)$/) || "700", radius: safeCssValue(styleBase(node, "borderRadius"), /^\d+(?:\.\d+)?(?:px|rem|em|%)$/) || "6px", }); const buttonVisualCss = (node: BlockNode) => { const values = buttonVisuals(node); return `color:${values.color};font-size:${values.fontSize};font-weight:${values.fontWeight};border-radius:${values.radius}`; }; const buttonAlignment = (node: BlockNode, context: unknown) => { const value = safeCssValue(styleBase(node, "textAlign"), /^(left|center|right|start|end)$/) || "start"; const rtl = (context as { direction?: string } | undefined)?.direction === "rtl"; return value === "start" ? (rtl ? "right" : "left") : value === "end" ? (rtl ? "left" : "right") : value; }; const emailCell = (body: string, node: BlockNode, align?: string) => `${body}`; export const emailDocumentBlock = defineBlock({ manifest: { type: "layout.email-document", version: 1, name: label("blocks.document.name", "Document"), icon: "icon-[lucide--mail]", category: "layout", capabilities: ["root"], props: object({ width: brandWidth, background: brandColor("#f3f4f6"), contentBackground: brandColor("#ffffff"), fontFamily: brandFont, textColor: brandColor("#111827"), linkColor: brandColor("#2563eb"), logoUrl: safeUrl({ schemes: ["https"], optional: true }), logoAlt: string({ max: 300, default: "" }), }) as never, styles, slots: { content: contentSlot }, }, create: ({ id }) => ({ id: id(), type: "layout.email-document", version: 1, props: { width: "600", background: "#f3f4f6", contentBackground: "#ffffff", fontFamily: "Arial, sans-serif", textColor: "#111827", linkColor: "#2563eb", logoUrl: "", logoAlt: "" }, styles: {}, slots: { content: [] }, }), renderers: { emailHtml: ({ node, slots }) => `
${attrs(node).logoUrl ? `${html(attrs(node).logoAlt)}` : ""}${slots.content}
`, ampEmail: ({ node, slots }) => ``, mjml: ({ node, slots, context }) => `${mjmlDocumentHead(node, context)}${attrs(node).logoUrl ? `` : ""}${slots.content}`, }, textRenderer: ({ slots }) => slots.content, } satisfies BlockDefinition); export const sectionBlock = defineBlock({ manifest: { type: "layout.section", version: 1, name: label("blocks.section.name", "Section"), icon: "icon-[lucide--panel-top]", category: "layout", capabilities: ["content"], props: object({ background: string({ default: "#ffffff" }), padding: string({ default: "24px" }), }) as never, styles, slots: { content: contentSlot }, }, create: ({ id }) => ({ id: id(), type: "layout.section", version: 1, props: { background: "#ffffff", padding: "24px" }, styles: {}, slots: { content: [] }, }), renderers: { emailHtml: ({ node, slots }) => emailCell( `
${slots.content}
`, node, ), ampEmail: ({ node, slots }) => emailCell( `
${slots.content}
`, node, ), mjml: ({ node, slots }) => { const children = node.slots.content ?? []; const body = children.length === 1 && children[0]?.type === "layout.columns" ? slots.content : `${slots.content}`; return `${body}`; }, }, textRenderer: ({ slots }) => `${slots.content}\n`, } satisfies BlockDefinition); export const columnsBlock = defineBlock({ manifest: { type: "layout.columns", version: 1, name: label("blocks.columns.name", "Columns"), icon: "icon-[lucide--columns-3]", category: "layout", capabilities: ["content"], props: empty, styles, slots: { columns: { accepts: { types: ["layout.column"] }, minChildren: 1, maxChildren: 4, orientation: "horizontal", sortable: true, }, }, }, create: ({ id }) => ({ id: id(), type: "layout.columns", version: 1, props: {}, styles: {}, slots: { columns: [] }, }), renderers: { emailHtml: ({ node, slots }) => emailCell( `${slots.columns}
`, node, ), ampEmail: ({ node, slots }) => emailCell(`${slots.columns}
`, node), mjml: ({ node, slots, context }) => isEmailRootChild(node, context) ? `${slots.columns}` : slots.columns, }, textRenderer: ({ slots }) => slots.columns, } satisfies BlockDefinition); export const columnBlock = defineBlock({ manifest: { type: "layout.column", version: 1, name: label("blocks.column.name", "Column"), icon: "icon-[lucide--panel-left]", category: "layout", capabilities: ["column"], props: object({ width: string({ default: "100%" }) }) as never, styles, slots: { content: contentSlot }, }, create: ({ id }) => ({ id: id(), type: "layout.column", version: 1, props: { width: "100%" }, styles: {}, slots: { content: [] }, }), renderers: { emailHtml: ({ node, slots }) => `${slots.content}
`, ampEmail: ({ node, slots }) => `${slots.content}
`, mjml: ({ node, slots }) => `${slots.content}`, }, textRenderer: ({ slots }) => slots.content, } satisfies BlockDefinition); const leaf = ( type: string, name: string, icon: string, props: never, renderers: BlockDefinition["renderers"], textRenderer: BlockDefinition["textRenderer"], ): BlockDefinition => defineBlock({ manifest: { type, version: 1, name: label(`blocks.${type}.name`, name), icon, category: "content", capabilities: ["content"], props, styles, slots: {}, }, create: ({ id }) => ({ id: id(), type, version: 1, props: {}, styles: {}, slots: {} }), renderers: { ...renderers, ...(renderers?.mjml ? { mjml: async (input) => { const content = await renderers.mjml!(input); return isEmailRootChild(input.node, input.context) ? `${content}` : content; } } : {}), }, textRenderer, }); export const headingBlock = leaf( "content.heading", "Heading", "icon-[lucide--heading]", object({ text: string({ min: 1, max: 500, default: "Heading" }), level: choice(["1", "2", "3", "4"] as const, { default: "2" }), }) as never, { emailHtml: ({ node }) => emailCell( `${html(attrs(node).text)}`, node, ), ampEmail: ({ node }) => emailCell(`${html(attrs(node).text)}`, node), mjml: ({ node }) => `${html(attrs(node).text)}`, }, ({ node }) => `${String(attrs(node).text)}\n\n`, ); export const textBlock = leaf( "content.text", "Text", "icon-[lucide--text]", object({ text: string({ max: 200_000, default: "" }) }) as never, { emailHtml: ({ node }) => emailCell( `

${multilineText(attrs(node).text)}

`, node, ), ampEmail: ({ node }) => emailCell(`

${multilineText(attrs(node).text)}

`, node), mjml: ({ node }) => `${multilineText(attrs(node).text)}`, }, ({ node }) => `${String(attrs(node).text)}\n\n`, ); export const imageBlock = leaf( "content.image", "Image", "icon-[lucide--image]", object({ src: safeUrl({ schemes: ["https"] }), alt: string({ max: 1_000, default: "" }), width: imageDimension("600"), height: imageDimension("300"), decorative: boolean(false), }) as never, { emailHtml: ({ node }) => emailCell( `${html(attrs(node).decorative ? `, node, ), ampEmail: ({ node }) => emailCell( ``, node, ), mjml: ({ node }) => ``, }, ({ node }) => (attrs(node).decorative ? "" : `[${String(attrs(node).alt)}]\n\n`), ); export const buttonBlock = leaf( "content.button", "Button", "icon-[lucide--mouse-pointer-click]", object({ label: string({ min: 1, max: 200, default: "Button" }), href: safeUrl({ schemes: ["https", "mailto"] }), }) as never, { emailHtml: ({ node, context }) => emailCell( `
${html(attrs(node).label)}
`, node, ), ampEmail: ({ node, context }) => emailCell( `${html(attrs(node).label)}`, node, buttonAlignment(node, context), ), mjml: ({ node, context }) => `${html(attrs(node).label)}`, }, ({ node }) => `${String(attrs(node).label)}: ${String(attrs(node).href)}\n\n`, ); export const dividerBlock = leaf( "content.divider", "Divider", "icon-[lucide--minus]", empty, { emailHtml: ({ node }) => emailCell(`
`, node), ampEmail: ({ node }) => emailCell(`
`, node), mjml: () => ``, }, () => "----------------\n", ); export const spacerBlock = leaf( "content.spacer", "Spacer", "icon-[lucide--move-vertical]", object({ height: imageDimension("24", 0) }) as never, { emailHtml: ({ node }) => emailCell( `
 
`, node, ), ampEmail: ({ node }) => emailCell(`
 
`, node), mjml: ({ node }) => ``, }, () => "\n", ); export const videoBlock = leaf( "content.video", "Video", "icon-[lucide--video]", object({ title: string({ min: 1, max: 300, default: "Watch video" }), href: safeUrl({ schemes: ["https"] }), thumbnail: safeUrl({ schemes: ["https"] }), alt: string({ max: 1_000, default: "Video thumbnail" }), width: imageDimension("600"), height: imageDimension("338"), }) as never, { emailHtml: ({ node }) => emailCell(`${html(attrs(node).alt)}`, node), ampEmail: ({ node }) => emailCell(``, node), mjml: ({ node }) => ``, }, ({ node }) => `${String(attrs(node).title)}: ${String(attrs(node).href)}\n\n`, ); export const socialBlock = leaf( "content.social", "Social links", "icon-[lucide--share-2]", object({ label: string({ max: 200, default: "Follow us" }), facebook: safeUrl({ schemes: ["https"], optional: true }), instagram: safeUrl({ schemes: ["https"], optional: true }), linkedin: safeUrl({ schemes: ["https"], optional: true }), x: safeUrl({ schemes: ["https"], optional: true }), }) as never, { emailHtml: ({ node }) => emailCell(`

${html(attrs(node).label)}: ${[["Facebook",attrs(node).facebook],["Instagram",attrs(node).instagram],["LinkedIn",attrs(node).linkedin],["X",attrs(node).x]].filter(([,url])=>url).map(([name,url])=>`${name}`).join("")}

`, node), ampEmail: ({ node }) => emailCell(`

${html(attrs(node).label)}: ${[["Facebook",attrs(node).facebook],["Instagram",attrs(node).instagram],["LinkedIn",attrs(node).linkedin],["X",attrs(node).x]].filter(([,url])=>url).map(([name,url])=>`${name}`).join(" ")}

`, node), mjml: ({ node }) => `${html(attrs(node).label)}: ${[["Facebook",attrs(node).facebook],["Instagram",attrs(node).instagram],["LinkedIn",attrs(node).linkedin],["X",attrs(node).x]].filter(([,url])=>url).map(([name,url])=>`${name}`).join(" ")}`, }, ({ node }) => `${String(attrs(node).label)}: ${[["Facebook",attrs(node).facebook],["Instagram",attrs(node).instagram],["LinkedIn",attrs(node).linkedin],["X",attrs(node).x]].filter(([,url])=>url).map(([name,url])=>`${name}: ${url}`).join(" · ")}\n\n`, ); const countdownUrl = (node: BlockNode, context: unknown): string | undefined => { const timestamp = Date.parse(String(attrs(node).deadline)); if (!Number.isFinite(timestamp)) throw new Error("Countdown deadline must be a valid date."); const endpoint = (context as { countdownImageUrl?: string })?.countdownImageUrl; if (!endpoint) return undefined; const url = new URL(endpoint); url.searchParams.set("deadline", String(timestamp)); return html(url.href); }; export const countdownBlock = leaf( "content.countdown", "Countdown", "icon-[lucide--timer]", object({ label: string({ max: 300, default: "Offer ends" }), deadline: string({ min: 10, max: 40 }), fallback: string({ max: 300, default: "This offer has ended." }), }) as never, { emailHtml: ({ node, context }) => { const url = countdownUrl(node, context); return emailCell(`

${html(attrs(node).label)}:

${url ? `${html(attrs(node).label)} — ${html(attrs(node).deadline)}` : ""}`, node); }, ampEmail: ({ node, context }) => { const url = countdownUrl(node, context); return emailCell(`

${html(attrs(node).label)}:

${url ? `` : ""}`, node); }, mjml: ({ node, context }) => { const url = countdownUrl(node, context); return `${html(attrs(node).label)}: ${html(attrs(node).deadline)}${url ? `` : ""}`; }, }, ({ node }) => `${String(attrs(node).label)}: ${String(attrs(node).deadline)}\n${String(attrs(node).fallback)}\n\n`, ); export const surveyBlock = leaf( "content.survey", "Survey", "icon-[lucide--list-checks]", object({ question: string({ min: 1, max: 500 }), buttonLabel: string({ min: 1, max: 200, default: "Answer survey" }), href: safeUrl({ schemes: ["https"] }) }) as never, { emailHtml: ({ node }) => emailCell(`

${html(attrs(node).question)}

${html(attrs(node).buttonLabel)}

`, node), ampEmail: ({ node }) => emailCell(`

${html(attrs(node).question)}

${html(attrs(node).buttonLabel)}

`, node), mjml: ({ node }) => `${html(attrs(node).question)}${html(attrs(node).buttonLabel)}`, }, ({ node }) => `${String(attrs(node).question)}\n${String(attrs(node).buttonLabel)}: ${String(attrs(node).href)}\n\n`, ); export const feedItemBlock = leaf( "content.feed-item", "RSS article", "icon-[lucide--rss]", object({ width: imageDimension("600"), height: imageDimension("400"), feedUrl: safeUrl({ schemes: ["https"], optional: true }), title: string({ min: 1, max: 500 }), summary: string({ max: 2_000, default: "" }), href: safeUrl({ schemes: ["https"] }), image: safeUrl({ schemes: ["https"], optional: true }) }) as never, { emailHtml: ({ node }) => emailCell(`${marketingImage(node, "html")}

${html(attrs(node).title)}

${html(attrs(node).summary)}

`, node), ampEmail: ({ node }) => emailCell(`${marketingImage(node, "amp")}

${html(attrs(node).title)}

${html(attrs(node).summary)}

`, node), mjml: ({ node }) => `${marketingImage(node, "mjml")}${html(attrs(node).title)}${html(attrs(node).summary)}`, }, ({ node }) => `${String(attrs(node).title)}\n${String(attrs(node).summary)}\n${String(attrs(node).href)}\n\n`, ); export const productBlock = leaf( "commerce.product", "Product", "icon-[lucide--shopping-bag]", object({ width: imageDimension("600"), height: imageDimension("400"), provider: choice(["shopify", "woocommerce", "manual"] as const, { default: "manual" }), storeUrl: safeUrl({ schemes: ["https"], optional: true }), productId: string({ max: 300, default: "" }), title: string({ min: 1, max: 500 }), price: string({ max: 100 }), image: safeUrl({ schemes: ["https"] }), href: safeUrl({ schemes: ["https"] }), buttonLabel: string({ max: 200, default: "Shop now" }) }) as never, { emailHtml: ({ node }) => emailCell(`${marketingImage(node, "html")}

${html(attrs(node).title)}

${html(attrs(node).price)}

${html(attrs(node).buttonLabel)}

`, node), ampEmail: ({ node }) => emailCell(`${marketingImage(node, "amp")}

${html(attrs(node).title)}

${html(attrs(node).price)}

${html(attrs(node).buttonLabel)}

`, node), mjml: ({ node }) => `${marketingImage(node, "mjml")}${html(attrs(node).title)}${html(attrs(node).price)}${html(attrs(node).buttonLabel)}`, }, ({ node }) => `${String(attrs(node).title)} — ${String(attrs(node).price)}\n${String(attrs(node).buttonLabel)}: ${String(attrs(node).href)}\n\n`, ); export const emailCoreBlocks: readonly BlockDefinition[] = [ emailDocumentBlock, sectionBlock, columnsBlock, columnBlock, headingBlock, textBlock, imageBlock, buttonBlock, dividerBlock, spacerBlock, videoBlock, socialBlock, countdownBlock, surveyBlock, feedItemBlock, productBlock, ];