Handlebars email template helpers: working versions (tested 2026-10-15)
Compile Handlebars email layouts with partials and helpers, covering conditionals, loops and HTML escaping offline.
passed2 passed · 0 failed2026-10-15 16:00 UTC- Status
- passed
- Last passed
- 2026-10-15 16:00 UTC
- Tests
- 2 / 2
- Language
- TypeScript
- Runtime
- node v22.23.3
pinned versions3
handlebars@4.7.10@types/node@26.6.5tsx@4.23.15
run it
npm install --no-audit --no-fund --ignore-scripts=false npm test
test environment
node v22.23.3 linux 6.17.0-1022-azure x64 runner: github-actions 38764699834
The code
Exactly the files that passed. Your agent can pull them with get_recipe("handlebars-email-templates") (install), or see them on GitHub.
package.json
{
"name": "handlebars-email-templates",
"private": true,
"type": "module",
"scripts": {
"test": "node --import tsx --test test/*.test.ts"
},
"dependencies": {"handlebars":"4.7.10"},
"devDependencies": {"@types/node":"26.6.5","tsx":"4.23.15"}
}
src/templates.ts
import Handlebars from "handlebars";
export type InvoiceItem = { name: string; qty: number; price: number };
Handlebars.registerHelper("money", (n: number) => `$${(Number(n) || 0).toFixed(2)}`);
Handlebars.registerHelper("eq", (a: unknown, b: unknown) => a === b);
const layout = Handlebars.compile(`<!doctype html>
<html><body>
{{> header}}
<main>{{{body}}}</main>
</body></html>`);
Handlebars.registerPartial("header", "<header><h1>{{brand}}</h1></header>");
const invoiceBody = Handlebars.compile(`
<p>Hello {{name}},</p>
{{#if premium}}<p>Thanks for being premium.</p>{{/if}}
<ul>
{{#each items}}
<li>{{name}} x{{qty}} - {{money price}}</li>
{{/each}}
</ul>
<p>Total: {{money total}}</p>
`);
export function renderInvoice(data: {
brand: string;
name: string;
premium: boolean;
items: InvoiceItem[];
}) {
const total = data.items.reduce((s, i) => s + i.qty * i.price, 0);
const body = invoiceBody({ ...data, total });
return layout({ brand: data.brand, body });
}
test/templates.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { renderInvoice } from "../src/templates.ts";
test("renders invoice with partials and helpers", () => {
const html = renderInvoice({
brand: "ProvenPin",
name: "Ada",
premium: true,
items: [
{ name: "Widget", qty: 2, price: 9.5 },
{ name: "Gadget", qty: 1, price: 4 },
],
});
assert.match(html, /<h1>ProvenPin<\/h1>/);
assert.match(html, /Hello Ada/);
assert.match(html, /premium/);
assert.match(html, /Widget x2/);
assert.match(html, /\$9\.50/);
assert.match(html, /Total: \$23\.00/);
});
test("escapes HTML in names", () => {
const html = renderInvoice({
brand: "X",
name: "<script>x</script>",
premium: false,
items: [],
});
assert.doesNotMatch(html, /<script>x<\/script>/);
assert.match(html, /<script>/);
});
Connect to ProvenPin
Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.
terminal
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp
~/.cursor/mcp.json
{ "mcpServers": { "provenpin": { "url": "https://mcp.provenpin.com/mcp" } } }.vscode/mcp.json
{ "servers": { "provenpin": { "type": "http", "url": "https://mcp.provenpin.com/mcp" } } }~/.codeium/windsurf/mcp_config.json
{ "mcpServers": { "provenpin": { "serverUrl": "https://mcp.provenpin.com/mcp" } } }MCP settings
{ "mcpServers": { "provenpin": { "type": "streamableHttp", "url": "https://mcp.provenpin.com/mcp" } } }Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.