The editor never produces HTML. What it produces is a document: a plain,
serialisable structure of { version, blocks } that you persist as JSON and
render on the server when a campaign goes out. That split is deliberate. The
sent email belongs to your server, which knows the recipient's name, locale
and unsubscribe link; the editor's job is to guarantee the shape of the input.
The package therefore ships no HTML renderer, and does not pretend to. It
ships the types, and the types are the contract: every block is a small
readonly object with a type field, the whole document is AI-readable data,
and a renderer is one exhaustive switch. This page walks the shape.
The document
import type { EmailEditorBlock, EmailEditorDocument, EmailEditorMoney, EmailEditorTextSpan } from "@voila.dev/ui/email-block-editor";
// { version: 1, blocks: EmailEditorBlock[] }The version field is your migration hook: documents live in your database
for years, and a schema change bumps the constant so old documents can be
upgraded on read. The blocks are a flat list, except for the grid, which holds
one level of leaf children and can never contain another grid. That single
rule is what keeps a renderer a plain recursion of depth two.
Three conventions run through every block:
- Placeholders stay tokens. Text fields may carry
{{firstName}},{{lastName}}or{{email}}; substitution happens per recipient at render time, never in the editor. - Money is integers. A price is
{ amountInMinorUnits, currency }, never a pre-formatted string, so one campaign can go out in several locales. Format withIntl.NumberFormatat render. - Rich text is flat spans. A paragraph is a list of
{ text, bold?, italic?, underline?, href? }runs, with no nesting. Escape the text, wrap the marks, done.
Walking it
An honest skeleton, built on the real types. The switch is the whole
architecture: TypeScript narrows each case to its block interface, and the
never check at the end means a document can never contain a block your
renderer silently drops.
const escapeHtml = (value: string): string =>
value
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
const renderSpans = (spans: ReadonlyArray<EmailEditorTextSpan>): string =>
spans
.map((span) => {
let html = escapeHtml(span.text).replaceAll("\n", "<br>");
if (span.bold) html = `<b>${html}</b>`;
if (span.italic) html = `<i>${html}</i>`;
if (span.underline) html = `<u>${html}</u>`;
if (span.href !== undefined)
html = `<a href="${escapeHtml(span.href)}">${html}</a>`;
return html;
})
.join("");
const renderBlock = (block: EmailEditorBlock): string => {
switch (block.type) {
case "heading": {
const size = block.level === 1 ? "22px" : "17px";
return `<h${block.level} style="margin:0 0 12px;font-size:${size};color:#151b77">${escapeHtml(block.text)}</h${block.level}>`;
}
case "paragraph":
return `<p style="margin:0 0 12px;font-size:15px;line-height:1.6;color:#2a2a33">${renderSpans(block.spans)}</p>`;
case "button": {
const filled = block.variant === "primary";
return `<table role="presentation" width="100%"><tr><td align="${block.align}">
<a href="${escapeHtml(block.href)}" style="display:inline-block;padding:12px 24px;border-radius:8px;${
filled
? "background:#151b77;color:#ffffff"
: "border:1px solid #151b77;color:#151b77"
}">${escapeHtml(block.label)}</a>
</td></tr></table>`;
}
case "divider":
return `<hr style="border:none;border-top:1px solid #ececf1;margin:20px 0">`;
case "grid": {
const width = Math.floor(100 / block.desktopColumns);
const cells = block.children
.map(
(child) =>
`<td width="${width}%" style="vertical-align:top;padding:8px">${renderBlock(child)}</td>`,
)
.join("");
return `<table role="presentation" width="100%"><tr>${cells}</tr></table>`;
}
case "image":
case "list":
case "stat":
case "table":
case "article":
case "product":
case "offer":
case "rating":
// The remaining blocks follow the same pattern: read the fields,
// escape the text, emit table-based markup.
return "";
default:
return block satisfies never;
}
};
export const renderEmailHtml = (document: EmailEditorDocument): string =>
document.blocks.map(renderBlock).join("");Wrap the result in your own chrome: the 600px card, the branded header, the
footer with contact details and the unsubscribe link. The editor shows neutral
placeholders for exactly those parts (or your headerSlot/footerSlot)
because the server owns them.
Keeping the preview honest
The canvas colours and font live in theme.ts as EMAIL_COLOR and
EMAIL_FONT, and the snippet above hard-codes the same values. Your renderer
stays the source of truth; the constants only need to agree with it closely
enough that the canvas is an honest preview. If you rebrand the sent email,
change both in the same commit. See
Theming the canvas.
Prices and dates in the canvas are previewed in EMAIL_PREVIEW_LOCALE
(en-US by default), mirroring a renderer that formats per recipient:
const formatPrice = (price: EmailEditorMoney, locale: string): string =>
new Intl.NumberFormat(locale, {
style: "currency",
currency: price.currency,
}).format(price.amountInMinorUnits / 100);Two blocks earn a note:
- Grid.
mobileColumnsdefaults to 1 because plenty of email clients ignore media queries; render the desktop table and pin the mobile count inside a@mediablock for the clients that honour it. - Rating. Each of the five steps links to
block.hrefwith?rating=Nappended, so the five variants are five separately countable tracked links and the distribution falls out of your click stats.