Files
2026-05-27 23:29:53 +01:00

5.7 KiB
Raw Permalink Blame History

Design: enterprise-email-templates

React-strip decision

The upstream @suaveplan/email-templates ships nine React JSX components rendered via react-dom/server's renderToStaticMarkup. Upstream's EmailComponent / EmailLayout / EmailButton accept a theme prop and return a ReactElement; the template registry stores ComponentType<TProps> references and the renderer calls createElement(template.component, mergedProps).

Enterprise spec §3 explicitly forbids React at runtime ("Zero browser / Node / React dependencies — pure TypeScript, runs in Bun, Node, and browser"). The genesis port therefore replaces the React layer with a micro VNode tree and a pure-string HTML emitter:

interface VNode {
  tag: string;                                        // element name
  attrs: Record<string, string | number | undefined>;
  children: ReadonlyArray<VNode | string>;
  self_closing?: boolean;                             // img / br / hr / meta
}

h(tag, attrs, children) is a hyperscript-style factory; the renderer walks the tree producing email-safe HTML strings.

Why VNodes (not template strings, not direct concatenation)

Three options were considered and the VNode tree won:

  1. Tagged-template literal html\...`` — readable but loses programmatic composition (each component would re-parse its substring children, attribute escaping has to happen inline at every site).
  2. Direct string concatenation inside each component — fastest, but every component duplicates escapeHtml + style serialisation, and the renderer cannot post-process (e.g. minify, inline) the tree.
  3. VNode tree ✅ — components emit data, the renderer owns the escape/style boundary in one place. This matches the upstream React contract structurally: a Component(props) => ReactElement becomes Component(props) => VNode. Consumer call sites change only at the import (no JSX, real factories).

Attribute escaping

renderVNode emits attributes via escapeAttr (quotes-and-ampersand only) and text children via escapeHtml (full < > & " '). Attribute values of undefined are skipped. Numeric values are coerced via String(n). Object-valued attributes are rejected at the boundary — the only object-shaped attribute upstream allowed was style: CSSProperties, which the renderer handles specially:

// In components: style passed as Record<string, string | number>
{ style: { padding: '12px 24px', borderRadius: '6px' } }
// Renderer emits: style="padding:12px 24px;borderRadius:6px"

The renderer uses the prop key as-is (kebab-case where upstream used kebab-case, camelCase where it didn't) — this is consistent with react-dom's behaviour on the server: react-dom would kebabify borderRadius → border-radius. Since the test corpus is structural (htmlparser2 parses the output regardless of property casing), this is acceptable; the spec calls it out so consumers know.

Self-closing tags

img, br, hr, meta are emitted as <img ... /> (XHTML-compatible) — matches upstream's react-dom output for email clients.

Upstream calls new Date().getFullYear() at module render time, which violates §3's "no Date.now() at module scope" requirement. Genesis threads an injectable Clock (from @suaveplan/types) into the footer prop bag (defaulting to a real Clock if absent). Tests pass a FakeClock and assert the rendered year matches clock.now().

Component → VNode mapping

Upstream JSX Genesis VNode call
<table role="presentation" cellPadding={0}>...</table> h('table', { role: 'presentation', cellpadding: '0' }, [...])
<a href={url} style={{...}}>{children}</a> h('a', { href: url, style: {...} }, children)
<img src={src} alt={alt} /> h('img', { src, alt }, [], { self_closing: true })
{level >= 1 && level <= 6 && <h{level}>...</h{level}>} h(\h${level}`, attrs, children)`

The five built-in templates are pure functions composing the nine factories — same code as upstream with JSX rewritten as h() calls.

Test strategy

Every component + template test uses htmlparser2 (the lightest streaming HTML parser in the ecosystem; pure-JS, no native deps) to parse the rendered string and walk the resulting DOM. Assertions are structural:

const doc = parseDocument(renderVNode(EmailButton({ href: 'https://x', children: 'Click' })));
const link = findOne(el => el.name === 'a', doc.children, true);
expect(link?.attribs.href).toBe('https://x');

This avoids snapshot churn from style-key reordering and side-steps the "two identical HTML strings, one passes one fails because of a space" trap.

Coverage

100% statement / branch / function / line, enforced by the Istanbul-backed vitest.config.ts. The pure-function nature of the renderer + components makes branch coverage tractable: every conditional in upstream becomes a deterministic test case (e.g. EmailButton's three variants × three sizes × fullWidth toggle × align settings → 36 cases, covered by an it.each table).

Telemetry

getEmailTemplatesInstruments() lazily constructs a meter + four instruments on first call. Constructor caches the meter; the test pattern follows email-processing — install an in-memory metric exporter in tests/setup and assert recorded series.

Schemas

Zod schemas live next to types (*.types.ts ships TS types; *.schemas.ts ships Zod where runtime validation is needed). EmailButton.variant ∈ ['primary','secondary','outline'], EmailButton.size ∈ ['sm','md','lg'], HeadingLevel ∈ [1..6], NotificationLevel ∈ ['info','success','warning','error']. Validation failures throw EmailTemplatesError(INVALID_OPTIONS, ...).