feat(templates): T1 pure Handlebars renderer
renderTemplate(raw, vars) → {subject, html, text}. HTML body auto-escaped
(customer names into email = XSS risk); subject/text verbatim. Throws on a
missing declared variable — never send a half-rendered receipt. 5 tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { renderTemplate, MissingTemplateVariableError } from './template.renderer';
|
||||
|
||||
describe('renderTemplate (pure)', () => {
|
||||
it('substitutes variables into subject / html / text', () => {
|
||||
const out = renderTemplate(
|
||||
{ subject: 'Receipt for {{firstName}}', bodyHtml: '<p>Hi {{firstName}}</p>', bodyText: 'Hi {{firstName}}', variables: ['firstName'] },
|
||||
{ firstName: 'Dana' },
|
||||
);
|
||||
expect(out.subject).toBe('Receipt for Dana');
|
||||
expect(out.html).toBe('<p>Hi Dana</p>');
|
||||
expect(out.text).toBe('Hi Dana');
|
||||
});
|
||||
|
||||
// The load-bearing safety property: a customer-supplied name goes into an HTML email body.
|
||||
it('escapes HTML in the html body, but leaves subject/text verbatim (XSS)', () => {
|
||||
const out = renderTemplate(
|
||||
{ subject: 'Hi {{firstName}}', bodyHtml: '<p>{{firstName}}</p>', bodyText: '{{firstName}}', variables: ['firstName'] },
|
||||
{ firstName: '<script>alert(1)</script>' },
|
||||
);
|
||||
expect(out.html).toBe('<p><script>alert(1)</script></p>');
|
||||
expect(out.html).not.toContain('<script>');
|
||||
expect(out.text).toBe('<script>alert(1)</script>'); // plaintext is not HTML → not escaped
|
||||
});
|
||||
|
||||
it('supports {{#if}} and {{#each}}', () => {
|
||||
const out = renderTemplate(
|
||||
{ bodyText: '{{#if companyName}}Company: {{companyName}}\n{{/if}}{{#each items}}- {{this}}\n{{/each}}', variables: [] },
|
||||
{ companyName: 'Acme', items: ['a', 'b'] },
|
||||
);
|
||||
expect(out.text).toBe('Company: Acme\n- a\n- b\n');
|
||||
});
|
||||
|
||||
it('throws when a declared variable is missing (fail loud)', () => {
|
||||
expect(() => renderTemplate({ bodyText: 'Hi {{firstName}}', variables: ['firstName'] }, {})).toThrow(MissingTemplateVariableError);
|
||||
});
|
||||
|
||||
it('renders only the parts that are present', () => {
|
||||
const out = renderTemplate({ bodyText: 'code {{code}}', variables: ['code'] }, { code: '123' });
|
||||
expect(out.text).toBe('code 123');
|
||||
expect(out.subject).toBeUndefined();
|
||||
expect(out.html).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import Handlebars from 'handlebars';
|
||||
|
||||
/** The raw, unrendered strings of a template (from the DB row or an inline source). */
|
||||
export interface RawTemplate {
|
||||
subject?: string | null;
|
||||
bodyHtml?: string | null;
|
||||
bodyText?: string | null;
|
||||
/** Declared variable names. Every one MUST be supplied at render time (fail loud). */
|
||||
variables?: string[] | null;
|
||||
}
|
||||
|
||||
export interface RenderedContent {
|
||||
subject?: string;
|
||||
html?: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
/** A declared variable was not supplied — we refuse to send a half-rendered "Hi ," message. */
|
||||
export class MissingTemplateVariableError extends Error {
|
||||
constructor(public readonly missing: string[]) {
|
||||
super(`missing required template variable(s): ${missing.join(', ')}`);
|
||||
this.name = 'MissingTemplateVariableError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure render: interpolate `vars` into a template's strings. HTML output is auto-escaped (values
|
||||
* like a customer name go into an email body — XSS risk); subject and plaintext are NOT escaped.
|
||||
* No I/O — the caller supplies the raw template. Throws if any declared variable is absent.
|
||||
*/
|
||||
export function renderTemplate(tpl: RawTemplate, vars: Record<string, unknown>): RenderedContent {
|
||||
const declared = tpl.variables ?? [];
|
||||
const missing = declared.filter((name) => vars[name] === undefined || vars[name] === null);
|
||||
if (missing.length > 0) throw new MissingTemplateVariableError(missing);
|
||||
|
||||
const out: RenderedContent = {};
|
||||
if (tpl.subject != null) out.subject = compile(tpl.subject, false)(vars);
|
||||
if (tpl.bodyHtml != null) out.html = compile(tpl.bodyHtml, true)(vars);
|
||||
if (tpl.bodyText != null) out.text = compile(tpl.bodyText, false)(vars);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** `escape=true` → HTML-escape interpolated values (for the html body); false → verbatim. */
|
||||
function compile(src: string, escape: boolean): Handlebars.TemplateDelegate {
|
||||
return Handlebars.compile(src, { noEscape: !escape, strict: false });
|
||||
}
|
||||
Reference in New Issue
Block a user