Initial Commit
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
# 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**:
|
||||
|
||||
```ts
|
||||
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:
|
||||
|
||||
```ts
|
||||
// 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.
|
||||
|
||||
### Footer year
|
||||
|
||||
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**:
|
||||
|
||||
```ts
|
||||
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, ...)`.
|
||||
Reference in New Issue
Block a user