first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,118 @@
# @suaveplan/email-templates - Universal React-free email template engine: VNode factories, nine reusable components, five built-in templates (welcome, verification, password-reset, notification, invoice), typed registry, fluent builder, theme system, structured errors, OTel instruments. Produces email-safe HTML strings with inline styles; runs in Bun, Node, and browser.
Universal, React-free email template engine. Produces email-safe HTML
strings with inline styles via VNode factories — runs identically in Bun,
Node, and the browser.
<!-- begin:auto-docs -->
## Documentation tree
- [email-button](./src/components/email-button/email-button.md) — `EmailButton` is the canonical call-to-action surface in an email body — a table-wrapped `<a>` with variant-driven them…
- [email-button](./src/components/email-button/email-button.md) — `EmailButton` is the canonical call-to-action surface in an email body — a table-wrapped `<a>` with variant-driven them…
- [email-container](./src/components/email-container/email-container.md) — `EmailContainer` groups related content inside an email body — a callout box, an info banner, a code-card. It emits a s…
- [email-container](./src/components/email-container/email-container.md) — `EmailContainer` groups related content inside an email body — a callout box, an info banner, a code-card. It emits a s…
- [email-divider](./src/components/email-divider/email-divider.md) — A horizontal divider rendered as a single-row `<table>` whose `<td>` carries `border-top: 1px solid <color>`. The table…
- [email-divider](./src/components/email-divider/email-divider.md) — A horizontal divider rendered as a single-row `<table>` whose `<td>` carries `border-top: 1px solid <color>`. The table…
- [email-footer](./src/components/email-footer/email-footer.md) — Renders the email footer block — company copyright, optional address, optional list of inline links (typically Privacy…
- [email-footer](./src/components/email-footer/email-footer.md) — Renders the email footer block — company copyright, optional address, optional list of inline links (typically Privacy…
- [email-heading](./src/components/email-heading/email-heading.md) — Renders an `<h1>`…`<h6>` element with a level-keyed font-size and line-height schedule tuned for inbox-rendering ergono…
- [email-heading](./src/components/email-heading/email-heading.md) — Renders an `<h1>`…`<h6>` element with a level-keyed font-size and line-height schedule tuned for inbox-rendering ergono…
- [email-image](./src/components/email-image/email-image.md) — Renders an `<img>` inside a centring `<table>` with the canonical responsive-email defaults (`display:block`, `max-widt…
- [email-image](./src/components/email-image/email-image.md) — Renders an `<img>` inside a centring `<table>` with the canonical responsive-email defaults (`display:block`, `max-widt…
- [email-layout](./src/components/email-layout/email-layout.md) — `EmailLayout` is the root VNode every email template wraps its body in. It emits a 100%-width `<table>` with a themed m…
- [email-layout](./src/components/email-layout/email-layout.md) — `EmailLayout` is the root VNode every email template wraps its body in. It emits a 100%-width `<table>` with a themed m…
- [email-link](./src/components/email-link/email-link.md) — Themed inline `<a>` link with safe defaults (`target="_blank"`, `rel="noopener noreferrer"`) for body-copy hyperlinks.…
- [email-link](./src/components/email-link/email-link.md) — Themed inline `<a>` link with safe defaults (`target="_blank"`, `rel="noopener noreferrer"`) for body-copy hyperlinks.…
- [email-text](./src/components/email-text/email-text.md) — Paragraph component for email-body copy. `EmailText` emits a themed `<p>` with one of three size variants (`body` / `ca…
- [email-text](./src/components/email-text/email-text.md) — Paragraph component for email-body copy. `EmailText` emits a themed `<p>` with one of three size variants (`body` / `ca…
- [error-codes](./src/error-codes/error-codes.md) — Stable error-code registry for `@suaveplan/email-templates`. Every failure raised by the package extends `SuaveplanErro…
- [error-codes](./src/error-codes/error-codes.md) — Stable error-code registry for `@suaveplan/email-templates`. Every failure raised by the package extends `SuaveplanErro…
- [message](./src/message/message.md) — `createEmailMessage` glues the renderer to the addressing envelope, producing an `EmailMessageResult` plain object suit…
- [message](./src/message/message.md) — `createEmailMessage` glues the renderer to the addressing envelope, producing an `EmailMessageResult` plain object suit…
- [renderer](./src/renderer/renderer.md) — The renderer is the bridge between the `EmailTemplate<TProps>` contract and a final `{ html, text, subject }` triple re…
- [renderer](./src/renderer/renderer.md) — The renderer is the bridge between the `EmailTemplate<TProps>` contract and a final `{ html, text, subject }` triple re…
- [telemetry](./src/telemetry/telemetry.md) — Exposes the OpenTelemetry instrument set published by `@suaveplan/email-templates`. Every public render or registry mut…
- [telemetry](./src/telemetry/telemetry.md) — Exposes the OpenTelemetry instrument set published by `@suaveplan/email-templates`. Every public render or registry mut…
- [template-builder](./src/template-builder/template-builder.md) — `TemplateBuilder` is a fluent API for assembling an `EmailTemplate<TProps>` value step-by-step. It exists because const…
- [template-builder](./src/template-builder/template-builder.md) — `TemplateBuilder` is a fluent API for assembling an `EmailTemplate<TProps>` value step-by-step. It exists because const…
- [template-registry](./src/template-registry/template-registry.md) — `TemplateRegistry` is a type-safe in-memory store for `EmailTemplate` values keyed by name. It supports per-name lookup…
- [template-registry](./src/template-registry/template-registry.md) — `TemplateRegistry` is a type-safe in-memory store for `EmailTemplate` values keyed by name. It supports per-name lookup…
- [invoice](./src/templates/invoice/invoice.md) — The `invoiceTemplate` is the receipt / invoice / order-confirmation email. It renders a heading carrying the invoice nu…
- [invoice](./src/templates/invoice/invoice.md) — The `invoiceTemplate` is the receipt / invoice / order-confirmation email. It renders a heading carrying the invoice nu…
- [notification](./src/templates/notification/notification.md) — The `notificationTemplate` is the generic system-notification email — a configurable severity callout suitable for prod…
- [notification](./src/templates/notification/notification.md) — The `notificationTemplate` is the generic system-notification email — a configurable severity callout suitable for prod…
- [password-reset](./src/templates/password-reset/password-reset.md) — The `passwordResetTemplate` is the canonical password-reset message. It explains the request, exposes a reset CTA, comm…
- [password-reset](./src/templates/password-reset/password-reset.md) — The `passwordResetTemplate` is the canonical password-reset message. It explains the request, exposes a reset CTA, comm…
- [verification](./src/templates/verification/verification.md) — The `verificationTemplate` is the standard email-address verification flow message. It greets the recipient, optionally…
- [verification](./src/templates/verification/verification.md) — The `verificationTemplate` is the standard email-address verification flow message. It greets the recipient, optionally…
- [welcome](./src/templates/welcome/welcome.md) — The `welcomeTemplate` is the canonical post-signup onboarding email. It greets the recipient by name, summarises why th…
- [welcome](./src/templates/welcome/welcome.md) — The `welcomeTemplate` is the canonical post-signup onboarding email. It greets the recipient by name, summarises why th…
- [theme](./src/theme/theme.md) — Provides the theme tokens consumed by every email component — colours, typography, spacing, branding, border-radius, an…
- [theme](./src/theme/theme.md) — Provides the theme tokens consumed by every email component — colours, typography, spacing, branding, border-radius, an…
- [vnode](./src/vnode/vnode.md) — Defines the universal VNode primitive — a `{ tag, attrs, children }` triple plus a pure-string HTML renderer — tha…
- [vnode](./src/vnode/vnode.md) — Defines the universal VNode primitive — a `{ tag, attrs, children }` triple plus a pure-string HTML renderer — tha…
<!-- end:auto-docs -->
## What's included
- **Nine reusable components** — `EmailLayout`, `EmailContainer`,
`EmailHeading`, `EmailText`, `EmailLink`, `EmailImage`, `EmailDivider`,
`EmailButton`, `EmailFooter`.
- **Five built-in templates** — `welcomeTemplate`, `verificationTemplate`,
`passwordResetTemplate`, `notificationTemplate`, `invoiceTemplate`.
- **VNode core** — `h(tag, attrs, children)` hyperscript factory and a
pure-string `renderVNode` walker.
- **Renderer** — `renderToHtml(template, props, options?)` → `{ html, text, subject }`
with OTel instrumentation.
- **Theme system** — `defaultTheme`, `createTheme`, `mergeThemes`.
- **Type-safe registry** — `TemplateRegistry<TMap>` with category / tag /
author search.
- **Fluent builder** — `TemplateBuilder<TProps>` / `createTemplateBuilder<TProps>()`.
- **Adapter** — `createEmailMessage(template, props, recipient, options?)`
returning a plain-object envelope ready for SMTP / SES / SendGrid.
- **Structured errors** — `EmailTemplatesError` with six `EMAIL_TEMPLATES.*` codes.
## Quick start
```ts
import {
createEmailMessage,
welcomeTemplate,
} from "@suaveplan/email-templates";
const message = createEmailMessage(
welcomeTemplate,
{ recipientName: "Jane", ctaUrl: "https://app.example.test/get-started" },
{ to: "jane@example.test", from: "hello@suaveplan.com" },
);
await mailer.send(message);
```
## Architecture
Upstream `@suaveplan/email-templates` was a React component library
rendered via `react-dom/server`. The port honours the enterprise
spec §3 ("Zero browser / Node / React dependencies") by re-implementing
the full upstream surface as **VNode factories** — pure functions
returning `{ tag, attrs, children }` triples walked by a single
HTML-emitter (`renderVNode`).
The renderer is the sole point at which child text is HTML-escaped and
attributes are serialised. Components emit data; escaping happens once.
## Universal
- Zero React / DOM / Node-specific dependencies at runtime.
- Injectable `Clock` (`@suaveplan/types`) for deterministic footer year.
- OTel instruments via `@opentelemetry/api`.
- Pure-TypeScript implementation, 100% statement / branch / function /
line coverage.
## Sub-path exports
`@suaveplan/email-templates/vnode`, `…/theme`, `…/renderer`, `…/message`,
`…/template-builder`, `…/template-registry`, `…/components`, `…/templates`,
`…/errors`, `…/otel` — each exposes a focused slice of the surface for
tree-shake friendliness.

View File

@@ -0,0 +1,92 @@
# email-button
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`EmailButton` is the canonical call-to-action surface in an email body —
a table-wrapped `<a>` with variant-driven theme colours and three preset
sizes. The table wrapper is non-optional: Outlook on Windows uses a
Word-derived rendering engine that historically ignores `padding` on
inline anchors, so the only reliable way to ship a visually-correct CTA
into Outlook desktop is a `<table>` containing a centred `<td>` containing
the styled `<a>`.
The component centralises the variant + size recipe so callers don't
duplicate it at every CTA site, and validates `variant`/`size` against
the published enums at call time — typos turn into a typed
`EmailTemplatesError(INVALID_OPTIONS)` instead of a silently-malformed
button.
## Features
- Three variants — `primary`, `secondary`, `outline` — each pulling
colours from the active theme.
- Three sizes — `sm`, `md`, `lg` — each driving distinct padding +
font-size.
- `fullWidth` toggle emits `width: 100%` on both the wrapping `<table>`
and the `<a>` so the CTA spans its container.
- `align` prop emitted as the table's `align` attribute.
- Validation: unknown variant or size raises
`EmailTemplatesError(INVALID_OPTIONS)` and bumps an OTel counter
tagged `reason="invalid_button_variant"` or `"invalid_button_size"`.
- Safe link defaults (`target="_blank"`, `rel="noopener noreferrer"`).
## Usage: Basic
```ts
import { EmailButton } from "@suaveplan/email-templates/components";
import { renderVNode } from "@suaveplan/email-templates/vnode";
renderVNode(EmailButton({
href: "https://app.example.test/get-started",
children: "Get Started",
}));
```
## Usage: Advanced
A full-width secondary CTA with custom letter-spacing:
```ts
import { EmailButton } from "@suaveplan/email-templates/components";
import { renderVNode } from "@suaveplan/email-templates/vnode";
renderVNode(EmailButton({
href: "https://app.example.test/upgrade",
children: "Upgrade Now",
variant: "secondary",
size: "lg",
fullWidth: true,
style: { "letter-spacing": "0.5px" },
}));
```
## API Reference
| Prop | Type | Description |
| ----------- | --------------------------------- | ---------------------------------------------------- |
| `href` | `string` | CTA target URL. |
| `children` | `string` | Button label (string only — keeps the table cell-tight). |
| `theme` | `EmailTheme?` | Theme override. |
| `variant` | `"primary" \| "secondary" \| "outline"` | Defaults to `primary`. |
| `size` | `"sm" \| "md" \| "lg"` | Defaults to `md`. |
| `fullWidth` | `boolean?` | Defaults to `false`. |
| `align` | `"left" \| "center" \| "right"` | Outer table align; defaults to `"center"`. |
| `style` | `StyleObject?` | Additional anchor-level inline styles. |
## Implementation Notes
The variant style returns three keys (`background-color`, `color`,
`border`) plus the size returns two (`padding`, `font-size`); a switch
on the variant tag emits the right triple. Validation uses a `Set`
lookup — branch coverage is satisfied by one true-case test plus one
false-case test per validator.
The `box-sizing: border-box` declaration on the anchor ensures the
2 px border doesn't push the button over its intended outside width when
`fullWidth: true`. Without that declaration Outlook would render the
button slightly wider than its container in the `outline` variant where
the border weight matters most.

View File

@@ -0,0 +1,76 @@
# email-container
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`EmailContainer` groups related content inside an email body — a callout
box, an info banner, a code-card. It emits a single-row table wrapping a
themed `<td>` with configurable padding, background colour, alignment, and
optional style overrides. Containers stack vertically inside an
`EmailLayout`'s content cell.
The component is intentionally table-based: `display: flex` and CSS grid
do not render reliably in Outlook 2007–2019, so the canonical email-design
recipe is a `<table role="presentation">` per group with explicit
`cellpadding`/`cellspacing` zeroed.
## Features
- Configurable `padding` (defaults to `theme.spacing.md`), `background`,
`align` (`left` / `center` / `right`), and arbitrary `style` overrides.
- Themed border-radius emitted on the `<td>`.
- `width: 100%` so containers fill their parent cell.
- Margin-bottom of `theme.spacing.md` baked in so stacked containers
retain breathing room without each caller adding spacing manually.
## Usage: Basic
```ts
import { EmailContainer } from "@suaveplan/email-templates/components";
import { renderVNode } from "@suaveplan/email-templates/vnode";
renderVNode(EmailContainer({ background: "#fff7ed", children: "Heads up!" }));
```
## Usage: Advanced
Tinted callout with custom padding and a left accent border:
```ts
import { EmailContainer } from "@suaveplan/email-templates/components";
import { EmailText } from "@suaveplan/email-templates/components";
import { renderVNode } from "@suaveplan/email-templates/vnode";
renderVNode(EmailContainer({
background: "#fffbeb",
padding: "24px",
style: { "border-left": "4px solid #d97706" },
children: EmailText({ children: "Action required: verify your email." }),
}));
```
## API Reference
| Prop | Type | Description |
| ------------- | --------------------------------- | -------------------------------------------------------- |
| `children` | `EmailChildren?` | Inner content. |
| `theme` | `EmailTheme?` | Theme override. |
| `padding` | `string?` | CSS padding; defaults to `theme.spacing.md`. |
| `background` | `string?` | CSS `background-color`. |
| `align` | `"left" \| "center" \| "right"` | Cell `align` attribute; defaults to `"left"`. |
| `style` | `StyleObject?` | Additional inline styles merged into the `<td>`. |
## Implementation Notes
Caller-supplied `style` is merged **after** the component's own style
keys, so it can override any of them. This is intentional: a caller who
needs `border-radius: 0` (e.g. a full-width accent strip) should be able
to override the themed default without forking the component.
The wrapping `<table>` carries `role="presentation"` to mark it as
layout-only for assistive tech. The `<td>`'s `align` attribute (not just
`text-align`) is set because Outlook honours the legacy attribute more
reliably than the CSS shorthand inside a cell context.

View File

@@ -0,0 +1,66 @@
# email-divider
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
A horizontal divider rendered as a single-row `<table>` whose `<td>`
carries `border-top: 1px solid <color>`. The table-with-border-top
approach renders consistently across every email client — Outlook,
Gmail, Apple Mail, Outlook.com, mobile clients — while a plain `<hr>`
historically produced wildly different visual weights (some clients
emitting a 1 px line, others a 2 px etched groove, still others a 3D
ridge).
## Features
- Themed colour by default (`theme.colors.border`); per-divider override
via the `color` prop.
- Margin top + bottom of `theme.spacing.md` baked in.
- 1 px collapse-safe sizing (`font-size: 1px`, `line-height: 1px`)
prevents the empty cell from inflating the divider thickness in
clients that auto-fill empty `<td>` with the inherited font-size.
## Usage: Basic
```ts
import { EmailDivider } from "@suaveplan/email-templates/components";
import { renderVNode } from "@suaveplan/email-templates/vnode";
renderVNode(EmailDivider({}));
```
## Usage: Advanced
A coloured divider with extra top margin to separate two sections:
```ts
import { EmailDivider } from "@suaveplan/email-templates/components";
import { renderVNode } from "@suaveplan/email-templates/vnode";
renderVNode(EmailDivider({
color: "#22d3ee",
style: { "margin-top": "40px", "margin-bottom": "40px" },
}));
```
## API Reference
| Prop | Type | Description |
| ------- | -------------- | -------------------------------------------------------- |
| `theme` | `EmailTheme?` | Theme override (border colour, spacing). |
| `color` | `string?` | Explicit divider colour; overrides `theme.colors.border`. |
| `style` | `StyleObject?` | Additional `<td>` inline styles. |
## Implementation Notes
The cell contains a single non-breaking space character (rendered as a
literal space) to satisfy the few clients that collapse empty `<td>`
into zero-height boxes. Combined with the 1 px font-size + line-height,
the cell renders as the border alone — exactly 1 px tall.
The `<table>` wrapper uses `width: 100%` so the divider spans whatever
column it lives in. Vertical margins come from the table, not the cell,
so the divider sits inside the layout's vertical rhythm correctly.

View File

@@ -0,0 +1,92 @@
# email-footer
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Renders the email footer block — company copyright, optional address,
optional list of inline links (typically Privacy / Terms / Help), and an
optional unsubscribe link. The year is derived from an **injectable
`Clock`** so the module has no implicit `Date` dependency at the
component boundary — a critical property for snapshot-stable tests and
deterministic re-renders.
## Features
- Injectable `Clock` (defaults to a real `Date.now()` clock); the copyright
year is `new Date(clock.now()).getUTCFullYear()` so the value is
timezone-stable.
- Company name falls back to `theme.branding.companyName` when the prop
is omitted; explicit prop overrides the theme.
- Optional `address` paragraph and `unsubscribeUrl` anchor.
- Optional `links` list rendered as a centred paragraph separated by
` · ` mid-dots — Apple Mail and Gmail render the mid-dot reliably.
- Themed muted-foreground colour, top border, and padding.
- `children` slot for custom footer copy (rendered before the boilerplate).
## Usage: Basic
```ts
import { EmailFooter } from "@suaveplan/email-templates/components";
import { renderVNode } from "@suaveplan/email-templates/vnode";
renderVNode(EmailFooter({
companyName: "Acme Corp",
unsubscribeUrl: "https://acme.test/unsub?token=abc",
}));
```
## Usage: Advanced
A footer with custom legal copy, link row, address, and an injected
clock for deterministic test output:
```ts
import { EmailFooter } from "@suaveplan/email-templates/components";
import { renderVNode } from "@suaveplan/email-templates/vnode";
const fakeClock = { now: () => 1735689600000 }; // 2025-01-01
renderVNode(EmailFooter({
clock: fakeClock,
companyName: "Acme Corp",
address: "1 Acme Way, Springfield, IL 62701, USA",
links: [
{ label: "Privacy", href: "https://acme.test/privacy" },
{ label: "Terms", href: "https://acme.test/terms" },
{ label: "Help", href: "https://acme.test/help" },
],
unsubscribeUrl: "https://acme.test/unsub?token=abc",
children: "You're receiving this because you opted in on Jan 1.",
}));
```
## API Reference
| Prop | Type | Description |
| ----------------- | --------------------------------- | ---------------------------------------------------- |
| `theme` | `EmailTheme?` | Theme override. |
| `clock` | `Clock?` | Injectable wall-clock for copyright year. |
| `companyName` | `string?` | Overrides `theme.branding.companyName`. |
| `address` | `string?` | Postal address line. |
| `unsubscribeUrl` | `string?` | If set, emit an Unsubscribe anchor. |
| `links` | `EmailFooterLink[]?` | Inline link row (Privacy / Terms / Help / …). |
| `children` | `EmailChildren?` | Custom copy rendered before the boilerplate block. |
| `style` | `StyleObject?` | Additional inline styles on the footer `<td>`. |
## Implementation Notes
`year` is computed via `new Date(clock.now()).getUTCFullYear()` —
`getUTCFullYear()` (not `getFullYear()`) is chosen so the rendered year
matches the UTC calendar day, which is what the test corpus asserts.
The choice avoids the edge case where a non-UTC server renders
`new Date()` near year-end and produces a `getFullYear()` value that
disagrees with the message header `Date:`.
The `children` slot accepts arbitrary `EmailChildren`. The
implementation re-wraps `children` into a passthrough VNode and lifts
its flattened children list into the footer cell — this handles the
array case (where `children` itself is `[node, node]`) without
re-implementing the flatten logic.

View File

@@ -0,0 +1,77 @@
# email-heading
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Renders an `<h1>`…`<h6>` element with a level-keyed font-size and
line-height schedule tuned for inbox-rendering ergonomics. The default
level is `1`. Levels outside the `[1..6]` integer range raise
`EmailTemplatesError(INVALID_HEADING_LEVEL)` — silently falling back to
`h6` (the upstream behaviour) hides typos that would otherwise produce
the wrong semantic structure.
The font-size schedule (`28px`, `24px`, `20px`, `18px`, `16px`, `14px`)
is the conventional email-design ladder. Line-heights tighten as size
grows so larger headings don't pull excessive vertical whitespace into
the body.
## Features
- Per-level font-size + line-height schedule baked in.
- Themed `font-family`, `color`, and bottom margin (`theme.spacing.md`).
- `align` prop (`left` / `center` / `right`) emitted as `text-align`.
- `style` override merged after component defaults.
- Validation: `INVALID_HEADING_LEVEL` for non-integer / out-of-range
values; OTel counter (`validation_failure_total{reason="invalid_heading_level"}`)
bumped on rejection.
## Usage: Basic
```ts
import { EmailHeading } from "@suaveplan/email-templates/components";
import { renderVNode } from "@suaveplan/email-templates/vnode";
renderVNode(EmailHeading({ children: "Welcome aboard" }));
```
## Usage: Advanced
A right-aligned subheading with a brand colour:
```ts
import { EmailHeading } from "@suaveplan/email-templates/components";
import { renderVNode } from "@suaveplan/email-templates/vnode";
renderVNode(EmailHeading({
level: 2,
align: "right",
style: { color: "#22d3ee" },
children: "Subtitle",
}));
```
## API Reference
| Prop | Type | Description |
| ---------- | --------------------------------- | ---------------------------------------------------- |
| `children` | `EmailChildren?` | Heading text. |
| `level` | `HeadingLevel (1..6)?` | Heading tag selector; defaults to `1`. |
| `theme` | `EmailTheme?` | Theme override. |
| `align` | `"left" \| "center" \| "right"` | `text-align` CSS value. |
| `style` | `StyleObject?` | Additional inline styles. |
## Implementation Notes
The font-weight is set to `700` (semibold-bold) regardless of level —
emails benefit from slightly heavier headings than web because client
rendering pipelines often anti-alias more aggressively, washing out
medium-weight type. Components that need a light variant should override
via `style: { "font-weight": 500 }`.
Validation happens at call time (synchronously). The OTel counter bump
gives operators an early signal when a deployment is regressing into
out-of-range heading levels — typically caused by an int-overflow bug in
a template-mapping layer upstream.

View File

@@ -0,0 +1,85 @@
# email-image
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Renders an `<img>` inside a centring `<table>` with the canonical
responsive-email defaults (`display:block`, `max-width:100%`,
`border:0`, `outline:none`). The `alt` attribute is required — missing
alt text breaks accessibility and triggers spam-filter heuristics in
several large providers.
The component is the right choice for hero images, illustrations, and
inline graphics. For brand logos that benefit from MSO-specific
fall-throughs, callers may render an additional `<v:image>` block
wrapped in a `<!--[if mso]>` conditional via the renderer's `rawHtml`
helper.
## Features
- Required `alt` prop (TypeScript will refuse to compile a missing alt).
- Optional pixel `width` and `height` set as both HTML attributes and
CSS values for Outlook-on-desktop compatibility.
- `display:block` removes the inline-baseline gap that produces a thin
border below the image in some clients.
- `max-width:100%` makes images responsive — they scale down inside a
narrower container (mobile) but cap at their natural size on desktop.
- Wrapper `<table>` honours the `align` prop (`center` by default).
## Usage: Basic
```ts
import { EmailImage } from "@suaveplan/email-templates/components";
import { renderVNode } from "@suaveplan/email-templates/vnode";
renderVNode(EmailImage({
src: "https://cdn.example.test/hero.png",
alt: "A welcoming illustration",
width: 600,
height: 300,
}));
```
## Usage: Advanced
A left-aligned logo with rounded corners:
```ts
import { EmailImage } from "@suaveplan/email-templates/components";
import { renderVNode } from "@suaveplan/email-templates/vnode";
renderVNode(EmailImage({
src: "https://cdn.example.test/logo.png",
alt: "Acme",
width: 120,
height: 40,
align: "left",
style: { "border-radius": "4px" },
}));
```
## API Reference
| Prop | Type | Description |
| --------- | --------------------------------- | ---------------------------------------------------- |
| `src` | `string` | Image URL. |
| `alt` | `string` | Accessible alt text — required. |
| `width` | `number?` | Pixel width (HTML + CSS). |
| `height` | `number?` | Pixel height (HTML + CSS). |
| `theme` | `EmailTheme?` | Theme override (used for the bottom margin). |
| `align` | `"left" \| "center" \| "right"` | Cell alignment; defaults to `"center"`. |
| `style` | `StyleObject?` | Additional `<img>` inline styles. |
## Implementation Notes
The `<img>` is wrapped in a single-row `<table>` because Outlook
honours the table's `align` attribute reliably; CSS `text-align` on a
plain `<img>` would behave inconsistently. The wrapping `<td>` carries
the alignment hint while the `<img>` itself stays block-level.
`width` and `height` set both the HTML attributes and the inline CSS
because Outlook-on-Windows preserves the HTML attribute while modern
clients use the CSS; the two values must agree.

View File

@@ -0,0 +1,80 @@
# email-layout
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`EmailLayout` is the root VNode every email template wraps its body in. It
emits a 100%-width `<table>` with a themed muted background, an inner
max-width table centred horizontally, and an optional invisible preview-text
block that surfaces a snippet in the recipient's inbox-list view (Gmail,
Apple Mail, etc.) without appearing in the rendered body.
The table-in-table structure is the canonical email-layout idiom: full-width
clients (Outlook on desktop) honour the inner `<table>`'s pixel `max-width`
where most CSS would be stripped, while modern clients honour both and the
result looks identical.
## Features
- Table-in-table structure with `role="presentation"` so screen readers
treat the markup as layout, not tabular data.
- Optional `preview` prop rendering an invisible snippet (`display:none;
max-height:0; overflow:hidden`) the inbox list samples.
- Themed background, font-family, font-size, line-height, and foreground
colour applied to the outer table; themed border, border-radius, and
max-width applied to the inner table.
- Children rendered into the inner content cell at `padding: theme.spacing.lg`.
## Usage: Basic
```ts
import { EmailLayout } from "@suaveplan/email-templates/components";
import { h, renderVNode } from "@suaveplan/email-templates/vnode";
renderVNode(EmailLayout({
children: h("p", {}, "Hi Jane"),
}));
```
## Usage: Advanced
Pass a custom theme and preview text for inbox-list snippet:
```ts
import { EmailLayout } from "@suaveplan/email-templates/components";
import { h, renderVNode } from "@suaveplan/email-templates/vnode";
import { createTheme } from "@suaveplan/email-templates/theme";
const theme = createTheme({ colors: { muted: "#0f172a", primary: "#22d3ee" } });
renderVNode(EmailLayout({
theme,
preview: "Your invoice for January",
children: h("p", {}, "Body content"),
}));
```
## API Reference
| Prop | Type | Description |
| ---------- | ---------------- | -------------------------------------------------------- |
| `children` | `EmailChildren?` | Body nodes rendered into the inner cell. |
| `theme` | `EmailTheme?` | Theme override; defaults to `defaultTheme`. |
| `preview` | `string?` | Hidden preview text for inbox-list snippet. |
| `style` | `StyleObject?` | Reserved for future use; current renderer ignores. |
## Implementation Notes
The preview block emits a `<div>` (not a `<span>` or `<table>`) because
historically Outlook 2007 mis-handled `display:none` on inline elements
inside tables. The empty-preview branch is exercised by the `omits the
preview block` test to keep the conditional fully covered.
`maxWidth` lives on the **inner** table, not the outer one — the outer
table spans the viewport's full width to paint the muted background
edge-to-edge. The inner table's `max-width: 600px` is the standard
email-design recommendation; Apple's HIG suggests this number to ensure
single-column readability across watch / phone / mail-app contexts.

View File

@@ -0,0 +1,75 @@
# email-link
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Themed inline `<a>` link with safe defaults (`target="_blank"`,
`rel="noopener noreferrer"`) for body-copy hyperlinks. The default colour
is `theme.colors.primary` and underline is on; both are overridable.
The link component is intentionally minimal — for CTA-style call-to-action
buttons use `EmailButton`, which emits the table-based, MSO-friendly
fallback structure. `EmailLink` is for ordinary in-paragraph references.
## Features
- `target="_blank"` + `rel="noopener noreferrer"` baked in. These two
attributes together close the `window.opener` exfiltration class of
bugs that browsers historically left open for `target="_blank"` links.
- Themed primary colour by default; per-link override via `color` prop.
- Underline on by default (matches link-convention expectations); toggle
via `underline: false` for navigation-style links inside footers.
- `style` override merged after defaults.
## Usage: Basic
```ts
import { EmailLink } from "@suaveplan/email-templates/components";
import { renderVNode } from "@suaveplan/email-templates/vnode";
renderVNode(EmailLink({ href: "https://example.test", children: "click here" }));
```
## Usage: Advanced
A non-underlined branded link inside a footer:
```ts
import { EmailLink } from "@suaveplan/email-templates/components";
import { renderVNode } from "@suaveplan/email-templates/vnode";
renderVNode(EmailLink({
href: "https://example.test/privacy",
underline: false,
color: "#64748b",
style: { "font-size": "12px" },
children: "Privacy Policy",
}));
```
## API Reference
| Prop | Type | Description |
| ----------- | -------------- | ---------------------------------------------------- |
| `href` | `string` | The link target. |
| `children` | `EmailChildren?` | Link label. |
| `theme` | `EmailTheme?` | Theme override. |
| `color` | `string?` | Override the link colour (default: `theme.colors.primary`). |
| `underline` | `boolean?` | Defaults to `true`. |
| `style` | `StyleObject?` | Additional inline styles. |
## Implementation Notes
`href` is escaped via the standard attribute-escape path inside
`renderVNode` — no special handling. The component does NOT validate
that `href` is a well-formed URL; that lives at the caller boundary
(typically a Zod schema upstream).
The `target="_blank"` + `rel="noopener noreferrer"` combination is
non-negotiable: callers cannot opt out. Email-client renderers
historically treat the `_blank` target differently (some open the
default browser, some open an embedded preview), and the `rel` attribute
guards against the `window.opener` reverse-tabnabbing class regardless.

View File

@@ -0,0 +1,71 @@
# email-text
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Paragraph component for email-body copy. `EmailText` emits a themed `<p>`
with one of three size variants (`body` / `caption` / `lead`), an
alignment hint, and an opt-in muted-colour treatment for de-emphasised
copy like footnotes or expiry notices.
Plain `<p>` elements work in every email client without further coaxing
— this component exists to centralise the font-family / font-size /
line-height / colour cascade so callers don't restate it at every
paragraph site.
## Features
- Three variants — `body` (16/1.5), `caption` (13/1.4), `lead` (18/1.6).
- `align` prop emitted as `text-align`.
- `muted` toggle switches `color` to `theme.colors.mutedForeground`.
- Themed `font-family` and bottom margin (`theme.spacing.md`).
- `style` override merged last.
## Usage: Basic
```ts
import { EmailText } from "@suaveplan/email-templates/components";
import { renderVNode } from "@suaveplan/email-templates/vnode";
renderVNode(EmailText({ children: "Hi Jane, thanks for signing up." }));
```
## Usage: Advanced
A right-aligned muted caption for a one-time-code expiry notice:
```ts
import { EmailText } from "@suaveplan/email-templates/components";
import { renderVNode } from "@suaveplan/email-templates/vnode";
renderVNode(EmailText({
variant: "caption",
align: "right",
muted: true,
children: "This code expires in 5 minutes.",
}));
```
## API Reference
| Prop | Type | Description |
| ---------- | --------------------------------- | ---------------------------------------------------- |
| `children` | `EmailChildren?` | Paragraph content (text or nested links / spans). |
| `theme` | `EmailTheme?` | Theme override. |
| `variant` | `"body" \| "caption" \| "lead"` | Size variant; defaults to `body`. |
| `align` | `"left" \| "center" \| "right"` | `text-align`. |
| `muted` | `boolean?` | Switch to muted foreground colour. |
| `style` | `StyleObject?` | Additional inline styles. |
## Implementation Notes
The variant table is module-local — adding a fourth variant requires
extending both the type union and the table; the TypeScript compiler
refuses to accept a mismatched pair so partial extensions are impossible.
Line heights are intentionally unit-less (e.g. `"1.5"`) so they inherit
correctly into nested elements. Email clients honour unit-less
`line-height` more reliably than `1.5em` or `24px`.

View File

@@ -0,0 +1,87 @@
# error-codes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Stable error-code registry for `@suaveplan/email-templates`. Every failure
raised by the package extends `SuaveplanError` with a discriminant `code`
drawn from `EMAIL_TEMPLATES_CODES`, enabling programmatic dispatch by
consumers without coupling to error-message text.
The registry is the contract: every code listed here MUST be thrown by at
least one production code path under `src/`. Removing an entry without
removing its call site is forbidden by §2c of `AGENTS.md`.
## Features
- Six stable codes registered via `defineCodes("EMAIL_TEMPLATES", [...])`.
- `EmailTemplatesError` class with public constructor (so external callers
can synthesise an instance for re-throw / wrapping).
- `EmailTemplatesCode` discriminant union derived from the registry — every
`code` field is statically narrowable.
- Inherits OTel span-event adaptation, redaction, cause-chain traversal,
and i18n message-key resolution from `SuaveplanError`.
## Usage: Basic
```ts
import {
EMAIL_TEMPLATES_CODES,
EmailTemplatesError,
} from "@suaveplan/email-templates/errors";
throw new EmailTemplatesError(
EMAIL_TEMPLATES_CODES.UNKNOWN_TEMPLATE,
"email_templates.unknown_template",
);
```
## Usage: Advanced
Wrap an unexpected throwable, preserving its cause for the OTel span-event
adapter to surface in tracing:
```ts
import {
EMAIL_TEMPLATES_CODES,
EmailTemplatesError,
} from "@suaveplan/email-templates/errors";
try {
renderToHtml(template, props);
} catch (cause) {
throw new EmailTemplatesError(
EMAIL_TEMPLATES_CODES.RENDER_FAILURE,
"email_templates.render_failure",
{ cause: cause instanceof Error ? cause : new Error(String(cause)) },
);
}
```
## API Reference
| Symbol | Kind | Description |
| ---------------------------- | -------- | --------------------------------------------------- |
| `EMAIL_TEMPLATES_CODES` | const | Frozen record of `EMAIL_TEMPLATES.<NAME>` strings. |
| `EmailTemplatesCode` | type | Discriminant union of every registered code. |
| `EmailTemplatesError` | class | `SuaveplanError<EmailTemplatesCode>` subclass. |
Codes (six): `INVALID_TEMPLATE`, `INVALID_OPTIONS`, `RENDER_FAILURE`,
`DUPLICATE_NAME`, `UNKNOWN_TEMPLATE`, `INVALID_HEADING_LEVEL`.
## Implementation Notes
The `defineCodes` helper from `@suaveplan/error` returns a frozen
`Record<K, "EMAIL_TEMPLATES.K">` and side-steps the literal-string-typing
pitfall by inferring each key as a string-literal type. `EmailTemplatesCode`
is derived via `(typeof EMAIL_TEMPLATES_CODES)[keyof typeof ...]` so adding
or removing a code automatically widens or narrows the union — the
TypeScript compiler will refuse to throw an unknown code.
`name = "EmailTemplatesError"` is set in the constructor (not via static
class-name reflection) so that downcasting via the standard
`err.name === "EmailTemplatesError"` discriminant works under minified
production bundles. `timestamp` is supplied by `SuaveplanError`.

View File

@@ -0,0 +1,96 @@
# message
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`createEmailMessage` glues the renderer to the addressing envelope, producing
an `EmailMessageResult` plain object suitable for handing to an SMTP / SES /
SendGrid / Mailgun client. The shape matches the union of common
email-provider request bodies (`to`, `from`, `cc`, `bcc`, `replyTo`, `subject`,
`html`, `text`), so a single helper covers the contract every transport
implements.
This module is intentionally thin — it owns the recipient-envelope merge
and threads the theme + render options through to `renderToHtml`. All
domain logic lives in the renderer + template; this is the integration
surface consumers wire into their job queues.
## Features
- Spreads the recipient envelope first (allowing the render result to
shadow no addressing fields), then layers `{ subject, html, text }` on
top.
- Accepts `theme` and `renderOptions` independently. The `renderOptions`
type explicitly excludes `theme` to prevent the caller from passing it
twice through two different paths.
- Returns a plain object — every transport accepts plain objects.
## Usage: Basic
```ts
import { createEmailMessage } from "@suaveplan/email-templates/message";
import { welcomeTemplate } from "@suaveplan/email-templates/templates";
const msg = createEmailMessage(
welcomeTemplate,
{ recipientName: "Jane", ctaUrl: "https://app.example.com" },
{ to: "jane@example.com", from: "hello@suaveplan.com" },
);
await mailer.send(msg);
```
## Usage: Advanced
Layer cc/bcc/replyTo with a custom theme and minified HTML:
```ts
import { createEmailMessage } from "@suaveplan/email-templates/message";
import { createTheme } from "@suaveplan/email-templates/theme";
import { invoiceTemplate } from "@suaveplan/email-templates/templates";
const theme = createTheme({ branding: { companyName: "Acme" } });
const msg = createEmailMessage(
invoiceTemplate,
{
recipientName: "Jane",
invoiceNumber: "001",
invoiceDate: "2026-01-01",
items: [{ description: "Widget", quantity: 2, unitPrice: 10, total: 20 }],
subtotal: 20,
total: 20,
},
{
to: "jane@x",
from: "billing@acme.test",
cc: ["audit@acme.test"],
bcc: "archive@acme.test",
replyTo: "support@acme.test",
},
{ theme, renderOptions: { minify: true, previewText: "Your invoice" } },
);
```
## API Reference
| Symbol | Kind | Description |
| -------------------- | -------- | -------------------------------------------------------- |
| `createEmailMessage` | function | Build an `EmailMessageResult` from a template + envelope. |
Types: `EmailRecipient`, `CreateEmailMessageOptions`, `EmailMessageResult`.
## Implementation Notes
The function performs no validation — it trusts the caller. Address
validation belongs to the transport layer (or to a dedicated validator
upstream); attempting it here would either reject perfectly-legal
addresses (e.g. RFC-5321 quoted local parts) or be too permissive to be
useful. Subject is derived from `template.subject` via `resolveSubject`,
so subject-line interpolation runs through the same code path as the
HTML body.
The result object is a fresh allocation per call — no caching, no
pooling, no mutation of either input. That makes the helper trivially
parallelisable across worker threads.

View File

@@ -0,0 +1,107 @@
# renderer
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The renderer is the bridge between the `EmailTemplate<TProps>` contract and
a final `{ html, text, subject }` triple ready to hand to an SMTP / SES / SendGrid
client. It calls `template.component(merged_props)` to obtain a `VNode`,
walks the tree through `renderVNode`, wraps the output in a full
`<!doctype html>` document with the head boilerplate every email client
expects, and (by default) derives a plain-text fallback from the HTML body
via tag-stripping.
Every render emits OTel telemetry — `email_templates.render_total` counter
tagged by `template` and `outcome`, plus a duration histogram. Errors raised
by the component are caught and re-thrown as `EmailTemplatesError` with the
`RENDER_FAILURE` code unless they were already a typed error from this
package (those propagate unchanged).
## Features
- `renderToHtml(template, props, options?)` → `{ html, text, subject }`.
- `wrapInDocument(body, options?)` — applies the doctype + head boilerplate.
- `resolveSubject(template, props)` — pure helper for subject derivation.
- `escapeHtml(input)` / `stripHtml(html)` — public utilities reused by
upstream callers.
- Injectable `theme` and `Clock` via render options.
- Optional `minify: true` collapses whitespace between tags.
- Optional `plainText: false` skips text-body generation.
- MSO conditional comment (`<!--[if mso]>...<![endif]-->`) for Outlook
PPI normalisation lives inside `wrapInDocument`.
## Usage: Basic
```ts
import { renderToHtml } from "@suaveplan/email-templates/renderer";
import { welcomeTemplate } from "@suaveplan/email-templates/templates";
const { html, text, subject } = renderToHtml(welcomeTemplate, {
recipientName: "Jane",
ctaUrl: "https://app.example.com",
});
```
## Usage: Advanced
Inject a fake clock for deterministic footer year rendering and pre-build a
tenant-specific theme:
```ts
import { renderToHtml } from "@suaveplan/email-templates/renderer";
import { createTheme } from "@suaveplan/email-templates/theme";
import { welcomeTemplate } from "@suaveplan/email-templates/templates";
const theme = createTheme({
colors: { primary: "#ff6600" },
branding: { companyName: "Acme" },
});
const result = renderToHtml(
welcomeTemplate,
{ recipientName: "Jane", ctaUrl: "https://acme.test" },
{
theme,
clock: { now: () => 1735689600000 }, // 2025-01-01T00:00:00Z
previewText: "Welcome aboard, Jane!",
minify: true,
},
);
```
## API Reference
| Symbol | Kind | Description |
| ----------------- | -------- | ---------------------------------------------------- |
| `renderToHtml` | function | Render a template to `{ html, text, subject }`. |
| `wrapInDocument` | function | Wrap a body fragment in a full HTML document. |
| `resolveSubject` | function | Resolve `template.subject` (literal or function). |
| `escapeHtml` | function | Escape text for HTML inclusion. |
| `stripHtml` | function | Tag-strip + entity-decode for plain-text fallback. |
Types: `EmailTemplate<TProps>`, `RenderOptions`, `RenderResult`,
`HtmlDocumentOptions`.
## Implementation Notes
The renderer is **not** idempotent across `Date.now()` calls — render
duration is recorded as `Date.now() - start`. Components, however, never
read wall-clock time directly: the renderer threads an injected `Clock`
(falling back to a real-`Date.now()` clock) into the merged props bag, so
template-level determinism is preserved.
`stripHtml` is a regex pass — it does NOT round-trip nested HTML entities
or HTML5 ambiguous markup. Email body content is expected to be the
output of `renderVNode` (well-formed) and is reduced to a coarse
plain-text representation suitable for clients that fall back to the
text part. Production-grade plain-text rendering (line wrapping, link
extraction, list bullets) is the consumer's responsibility.
Errors raised inside `template.component` are caught, the `error` outcome
counter is bumped, and the original cause is preserved on the
`EmailTemplatesError`. Typed errors already raised by this package are
re-thrown without re-wrapping so their original `code` survives the
boundary intact.

View File

@@ -0,0 +1,81 @@
# telemetry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Exposes the OpenTelemetry instrument set published by
`@suaveplan/email-templates`. Every public render or registry mutation
emits a counter (and where useful, a duration histogram) so operators can
build dashboards without instrumenting consumers themselves.
The instrument set is constructed lazily on first access — importing this
module does NOT create a meter or instruments, satisfying the
"side-effects: false" contract in `package.json`. Once constructed, the
set is cached and the same object reference is returned forever (the
metric exporter receives stable instrument identity, which is required
for cumulative-temporality counters).
## Features
- Four instruments: `render_total` (Counter), `render_duration_ms`
(Histogram, unit `ms`), `registry_register_total` (Counter),
`validation_failure_total` (Counter).
- Stable meter name: `@suaveplan/email-templates`.
- `resetEmailTemplatesInstrumentsCacheForTest()` helper to drop the cache
between test runs that swap the global `MeterProvider`.
## Usage: Basic
```ts
import { getEmailTemplatesInstruments } from "@suaveplan/email-templates/otel";
const instruments = getEmailTemplatesInstruments();
instruments.renderCounter.add(1, { template: "welcome", outcome: "ok" });
```
## Usage: Advanced
Wire a real metric exporter in test setup and assert the recorded series
on every render call (the package uses this pattern internally):
```ts
import {
AggregationTemporality,
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
import { metrics } from "@opentelemetry/api";
const exporter = new InMemoryMetricExporter(AggregationTemporality.DELTA);
const reader = new PeriodicExportingMetricReader({ exporter });
const provider = new MeterProvider({ readers: [reader] });
metrics.setGlobalMeterProvider(provider);
// ... drive the renderer ...
await reader.forceFlush();
const series = exporter.getMetrics();
// assert series contains email_templates.render_total ...
```
## API Reference
| Symbol | Kind | Description |
| -------------------------------------------- | -------- | -------------------------------------------- |
| `getEmailTemplatesInstruments()` | function | Returns the cached `EmailTemplatesInstruments`. |
| `resetEmailTemplatesInstrumentsCacheForTest()` | function | Drops the module-local cache — test-only. |
| `EmailTemplatesInstruments` | type | The shape of the instrument set. |
## Implementation Notes
The accessor must remain idempotent. Repeated calls return the same
object reference; the test corpus asserts that fact because cumulative
counters lose their backing storage if a meter is re-created mid-flight.
The module never reads `process.env`, `globalThis.window`, or any
DOM-resident global — it is universal (`engines.bun`, `engines.node`,
browser) without conditional code paths.

View File

@@ -0,0 +1,84 @@
# template-builder
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`TemplateBuilder` is a fluent API for assembling an `EmailTemplate<TProps>`
value step-by-step. It exists because constructing the template literal
inline reads poorly when defaults and subject functions are non-trivial,
and because the builder enforces presence of the three required fields
(`name`, `subject`, `component`) at `build()` time with a typed error.
The class itself is a typical builder — each setter mutates the internal
state and returns `this`. `build()` is the one terminal operation. Calls
made after `build()` continue to work (the builder is not consumed), so a
single builder can produce two slightly-different templates by switching
one setter and calling `build()` twice.
## Features
- Fluent setters: `name`, `subject` (literal or function-of-props),
`component`, `defaultProps`.
- Runtime validation in `build()` — missing required fields raise
`EmailTemplatesError(INVALID_TEMPLATE)` with a specific message key.
- `createTemplateBuilder<TProps>()` factory for inference-friendly use.
## Usage: Basic
```ts
import { createTemplateBuilder } from "@suaveplan/email-templates/template-builder";
import { h } from "@suaveplan/email-templates/vnode";
interface Props { name: string; ctaUrl: string }
const myTemplate = createTemplateBuilder<Props>()
.name("custom-welcome")
.subject((p) => `Hi ${p.name}`)
.component((p) => h("a", { href: p.ctaUrl }, "Click me"))
.build();
```
## Usage: Advanced
Reuse a single builder to produce a localised template family by mutating
between `build()` calls:
```ts
import { createTemplateBuilder } from "@suaveplan/email-templates/template-builder";
import { h } from "@suaveplan/email-templates/vnode";
interface Props { name: string }
const b = createTemplateBuilder<Props>()
.component((p) => h("p", {}, `Hi ${p.name}`));
const en = b.name("greet-en").subject("Hello").build();
const fr = b.name("greet-fr").subject("Bonjour").build();
```
`en.subject === "Hello"` and `fr.subject === "Bonjour"` — the builder
returns a fresh `EmailTemplate` object on each `build()` call.
## API Reference
| Symbol | Kind | Description |
| ------------------------ | -------- | ---------------------------------------------------- |
| `TemplateBuilder<T>` | class | The builder itself; instance methods are chainable. |
| `createTemplateBuilder` | function | `<T>() => TemplateBuilder<T>` factory. |
| `TemplateBuilderApi<T>` | type | The interface implemented by `TemplateBuilder<T>`. |
| `EmailComponentFn<T>` | type | `(props: T & { theme?; clock? }) => VNode`. |
## Implementation Notes
The builder never throws inside a setter — invariants are checked
exclusively in `build()`. Empty-string `name` is treated as missing
(consistent with upstream behaviour). The `defaultProps` field is
optional; omitting it leaves it `undefined` on the produced template.
Because `build()` returns a fresh object every time, callers can mutate
the builder and reuse it for closely-related templates — useful for
i18n where the body component is identical and only the subject text
changes by locale.

View File

@@ -0,0 +1,102 @@
# template-registry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`TemplateRegistry` is a type-safe in-memory store for `EmailTemplate`
values keyed by name. It supports per-name lookup, listing, category /
tag / author search, and lifecycle removal — the surface a typical
email-orchestration service needs to dispatch the right template by
name without coupling to the template implementations themselves.
The registry is intentionally non-persistent: callers needing
cross-process state (DB-backed templates, CDN-hosted JSON, etc.) wire
their own store and `register()` into this registry on boot. Templates
remain plain `EmailTemplate` values; the registry never copies or
freezes them.
## Features
- `register(name, template, metadata?)` with duplicate detection and an
OTel counter (`registry_register_total{outcome}`).
- `get(name)` / `has(name)` / `list()` / `size` accessor.
- `search(criteria)` with name (case-insensitive substring), category
(exact), tag (any-intersection), author (exact) filters — combinable.
- `remove(name)` / `clear()` for lifecycle management.
- Optional `defaultTheme` exposed via a getter, useful as a per-registry
fallback for the renderer.
## Usage: Basic
```ts
import { createRegistry } from "@suaveplan/email-templates/template-registry";
import {
invoiceTemplate,
welcomeTemplate,
} from "@suaveplan/email-templates/templates";
const registry = createRegistry();
registry.register("welcome", welcomeTemplate, {
category: "transactional",
tags: ["onboarding"],
});
registry.register("invoice", invoiceTemplate, { category: "billing" });
const t = registry.get("welcome");
```
## Usage: Advanced
Wire a typed map so `get(name)` returns the exact `EmailTemplate<TProps>`
for that key:
```ts
import { createRegistry } from "@suaveplan/email-templates/template-registry";
import { welcomeTemplate } from "@suaveplan/email-templates/templates";
import type { WelcomeProps } from "@suaveplan/email-templates/templates";
interface MyMap {
welcome: WelcomeProps;
}
const registry = createRegistry<MyMap>();
registry.register("welcome", welcomeTemplate);
const welcomeT = registry.get("welcome"); // type: EmailTemplate<WelcomeProps> | undefined
```
Search by tag intersection and category:
```ts
registry.search({ tags: ["onboarding"], category: "transactional" });
```
## API Reference
| Symbol | Kind | Description |
| ------------------------- | -------- | ---------------------------------------------------- |
| `TemplateRegistry<TMap>` | class | The registry itself; implements `TemplateRegistryApi`. |
| `createRegistry<TMap>` | function | Convenience factory returning a new registry. |
| `RegistryOptions` | type | Constructor options. |
| `TemplateMetadata` | type | `{ category?, tags?, description?, author? }`. |
| `RegistryEntry` | type | Internal entry shape (template + metadata + when). |
| `SearchCriteria` | type | Search input shape. |
| `SearchResult` | type | Search output shape. |
| `TemplateMap` | type | `Record<string, unknown>` upper bound. |
| `TemplateRegistryApi` | type | The interface implemented by `TemplateRegistry`. |
## Implementation Notes
The registry stores its entries in a single `Map<string, RegistryEntry>`
— ordered insertion is preserved for `list()` and `search()`. Duplicate
registration raises `EmailTemplatesError(DUPLICATE_NAME)` deliberately:
silent overwrite is the most common cause of "wrong template ended up in
the inbox" bugs. Consumers needing replacement should `remove(name)`
first.
`search` uses simple JS `Array.some` / `String.includes` — no fuzzy
matching, no scoring. Tag matching is an OR (any tag in the criterion
must appear in the entry's tags); name matching is substring; category
and author are exact. The criteria are combined with logical AND.

View File

@@ -0,0 +1,93 @@
# invoice
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `invoiceTemplate` is the receipt / invoice / order-confirmation
email. It renders a heading carrying the invoice number, a metadata line
(date, optional due date), a line-items table with description /
quantity / unit-price / total columns, a subtotal-tax-total summary, and
an optional "Pay Now" CTA when `paymentUrl` is supplied.
The template is intentionally generic — it does not assume a specific
tax-jurisdiction or currency formatting; consumers pass pre-formatted
strings (date, currency symbol) and pre-computed numbers (subtotal,
tax, total). The template focuses on layout, not arithmetic.
## Features
- Line-items table with description, quantity, unit price, total columns.
- Subtotal + optional tax + total summary at right-align.
- Optional `paymentUrl` renders a "Pay Now" CTA.
- Currency symbol is configurable (`currency` defaults to `"$"`).
- Optional `dueDate` appears alongside the invoice date.
## Usage: Basic
```ts
import { renderToHtml } from "@suaveplan/email-templates/renderer";
import { invoiceTemplate } from "@suaveplan/email-templates/templates";
renderToHtml(invoiceTemplate, {
recipientName: "Jane",
invoiceNumber: "INV-001",
invoiceDate: "2026-01-15",
items: [
{ description: "Pro plan – Jan 2026", quantity: 1, unitPrice: 29, total: 29 },
],
subtotal: 29,
total: 29,
});
```
## Usage: Advanced
A multi-line invoice with tax, a due date, a payment CTA, and a
non-USD currency:
```ts
import { renderToHtml } from "@suaveplan/email-templates/renderer";
import { invoiceTemplate } from "@suaveplan/email-templates/templates";
renderToHtml(invoiceTemplate, {
recipientName: "Jane",
invoiceNumber: "INV-001",
invoiceDate: "2026-01-15",
dueDate: "2026-01-31",
currency: "€",
items: [
{ description: "Pro plan – Jan 2026", quantity: 1, unitPrice: 29, total: 29 },
{ description: "Add-on: extra seat", quantity: 2, unitPrice: 5, total: 10 },
],
subtotal: 39,
tax: 3.9,
total: 42.9,
paymentUrl: "https://billing.example.test/pay?invoice=INV-001",
});
```
## API Reference
| Symbol | Kind | Description |
| ----------------- | -------- | ---------------------------------------------------- |
| `invoiceTemplate` | const | `EmailTemplate<InvoiceProps>`. |
| `InvoiceEmail` | function | Component returning a `VNode`. |
| `InvoiceProps` | type | `{ recipientName, invoiceNumber, invoiceDate, dueDate?, items, subtotal, tax?, total, currency?, paymentUrl?, companyName? }` |
| `InvoiceLineItem` | type | `{ description, quantity, unitPrice, total }`. |
## Implementation Notes
Currency formatting uses `Number.prototype.toFixed(2)` — locale-agnostic
two-decimal-place rendering. Consumers needing locale-aware grouping
separators (e.g. `1,234.56` vs `1 234,56`) should pre-format the strings
upstream; the template intentionally does not depend on `Intl.NumberFormat`
because email-template environments span Node, Bun, and browsers where
the Intl data sets vary.
The line-items table renders a single `<tbody>` per call; the table
header sits in its own `<thead>` so accessibility scanners report the
correct table-with-headers structure even though most email clients
ignore the distinction.

View File

@@ -0,0 +1,83 @@
# notification
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `notificationTemplate` is the generic system-notification email — a
configurable severity callout suitable for product-health alerts,
audit-trail events, account-status changes, and similar operational
messages. The `level` prop drives both the accent border colour and the
container background, giving recipients an at-a-glance severity cue.
## Features
- Four severity levels — `info`, `success`, `warning`, `error` — each
selecting a distinct accent / background colour pair.
- Optional `timestamp` (rendered as a muted caption above the action).
- Optional `action` button (`{ label, url }`).
- Subject derives from the `title` prop.
- Validation: unknown level raises `EmailTemplatesError(INVALID_OPTIONS)`
and bumps an OTel counter.
## Usage: Basic
```ts
import { renderToHtml } from "@suaveplan/email-templates/renderer";
import { notificationTemplate } from "@suaveplan/email-templates/templates";
renderToHtml(notificationTemplate, {
recipientName: "Jane",
title: "Heads up",
message: "Your subscription renews tomorrow.",
level: "info",
});
```
## Usage: Advanced
A warning notification with a timestamp and a follow-up action:
```ts
import { renderToHtml } from "@suaveplan/email-templates/renderer";
import { notificationTemplate } from "@suaveplan/email-templates/templates";
renderToHtml(notificationTemplate, {
recipientName: "Jane",
title: "Failed login from a new device",
message: "We blocked an attempt from a device we don't recognise. If this was you, you can dismiss this message.",
level: "warning",
timestamp: "2026-05-25 14:32 UTC",
action: {
label: "Review device activity",
url: "https://app.example.test/security/devices",
},
});
```
## API Reference
| Symbol | Kind | Description |
| ----------------------- | -------- | ---------------------------------------------------- |
| `notificationTemplate` | const | `EmailTemplate<NotificationProps>`. |
| `NotificationEmail` | function | Component returning a `VNode`. |
| `NotificationProps` | type | `{ recipientName, title, message, level?, action?, companyName?, timestamp? }` |
| `NotificationLevel` | type | `"info" \| "success" \| "warning" \| "error"`. |
| `NotificationAction` | type | `{ label, url }`. |
## Implementation Notes
The accent colour for `info`/`success`/`warning`/`error` resolves from
the theme's colour palette (`primary` for info; `success`/`warning`/`error`
respectively). The container background, however, is a fixed lighter
tint per level — those values are not in the theme because they're
visually-tuned washes specific to this template's callout pattern.
Consumers wanting a theme-driven wash can override via `EmailContainer`
directly.
The accent-bar effect is achieved with a `<div>` carrying
`border-left: 4px solid <accent>`. The bar lives inside the
`EmailContainer` cell so the container's themed border-radius rounds the
outer edges while the bar stays sharp.

View File

@@ -0,0 +1,83 @@
# password-reset
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `passwordResetTemplate` is the canonical password-reset message. It
explains the request, exposes a reset CTA, communicates the link's
expiry, and includes a "you can safely ignore" caution to reassure
recipients who did not initiate the request.
The template reproduces the reset URL as plain text underneath the CTA
button — a deliberate redundancy that ensures recipients on
text-stripping clients (some webmail security scanners) can still
complete the flow.
## Features
- Themed reset CTA pointing at `resetUrl`.
- Configurable `expiresInMinutes` (defaults to `60`).
- Tinted callout container with the "safely ignore" caution copy.
- Plain-URL fallback below the divider.
- Footer with injectable `Clock`.
## Usage: Basic
```ts
import { renderToHtml } from "@suaveplan/email-templates/renderer";
import { passwordResetTemplate } from "@suaveplan/email-templates/templates";
const { html, text, subject } = renderToHtml(passwordResetTemplate, {
recipientName: "Jane",
resetUrl: "https://app.example.test/reset?token=abc",
});
```
## Usage: Advanced
A 30-minute expiry tied to a tenant-specific brand:
```ts
import { renderToHtml } from "@suaveplan/email-templates/renderer";
import { createTheme } from "@suaveplan/email-templates/theme";
import { passwordResetTemplate } from "@suaveplan/email-templates/templates";
const theme = createTheme({
colors: { primary: "#22d3ee" },
branding: { companyName: "Acme" },
});
renderToHtml(
passwordResetTemplate,
{
recipientName: "Jane",
resetUrl: "https://acme.test/reset?token=abc",
expiresInMinutes: 30,
companyName: "Acme",
},
{ theme },
);
```
## API Reference
| Symbol | Kind | Description |
| ------------------------ | -------- | ---------------------------------------------------- |
| `passwordResetTemplate` | const | `EmailTemplate<PasswordResetProps>`. |
| `PasswordResetEmail` | function | Component returning a `VNode`. |
| `PasswordResetProps` | type | `{ recipientName, resetUrl, expiresInMinutes?, companyName? }` |
## Implementation Notes
The 60-minute default mirrors the most common security-team-approved
upper bound for password-reset links; tenants needing tighter security
should pass an explicit `expiresInMinutes` and ensure their token-issue
back-end uses the same value.
The "safely ignore" line lives inside the tinted callout to visually
separate it from the CTA, which discourages anxious recipients from
clicking the reset link they did not request — a known social-engineering
attack vector when the caution is text-only.

View File

@@ -0,0 +1,83 @@
# verification
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `verificationTemplate` is the standard email-address verification
flow message. It greets the recipient, optionally displays a one-time
code in a centred tinted container with tracked letter-spacing for
readability, exposes the verify-link CTA, and includes an expiry
notice.
The template handles both code-only and link-only verification flows by
making the `code` field optional. When omitted, the code container is
skipped entirely.
## Features
- Optional one-time code rendering in a tinted container (32 px,
bold, 8 px letter-spacing — the conventional "OTP display" treatment).
- Configurable `expiresInMinutes` (defaults to `30`).
- Themed verify-email CTA pointing at `verificationUrl`.
- Footer with injectable `Clock` for deterministic year.
- "If you didn't create an account" caution paragraph.
## Usage: Basic
```ts
import { renderToHtml } from "@suaveplan/email-templates/renderer";
import { verificationTemplate } from "@suaveplan/email-templates/templates";
const { html, text, subject } = renderToHtml(verificationTemplate, {
recipientName: "Jane",
verificationUrl: "https://app.example.test/verify?token=abc",
code: "ABC123",
});
```
## Usage: Advanced
Link-only verification with a tighter expiry and a tenant-specific
brand:
```ts
import { renderToHtml } from "@suaveplan/email-templates/renderer";
import { createTheme } from "@suaveplan/email-templates/theme";
import { verificationTemplate } from "@suaveplan/email-templates/templates";
const theme = createTheme({ branding: { companyName: "Acme" } });
renderToHtml(
verificationTemplate,
{
recipientName: "Jane",
verificationUrl: "https://acme.test/verify?token=abc",
expiresInMinutes: 5,
companyName: "Acme",
},
{ theme },
);
```
## API Reference
| Symbol | Kind | Description |
| ----------------------- | -------- | ------------------------------------------------- |
| `verificationTemplate` | const | `EmailTemplate<VerificationProps>`. |
| `VerificationEmail` | function | Component returning a `VNode`. |
| `VerificationProps` | type | `{ recipientName, verificationUrl, code?, expiresInMinutes?, companyName? }` |
## Implementation Notes
The empty-string check on `code` is intentional: an upstream sanitiser
that strips whitespace from a missing-value form field commonly produces
`""` rather than `undefined`. Treating both as "no code" keeps the
template stable against caller-side variance without requiring a Zod
schema at the boundary.
The OTP container's `letter-spacing: 8px` is wide enough that 6-digit
codes render as visually-grouped pairs in most clients, which improves
copy-by-eye accuracy. The font size (32 px) is large enough to remain
legible on a phone-sized email preview without dominating the layout.

View File

@@ -0,0 +1,84 @@
# welcome
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `welcomeTemplate` is the canonical post-signup onboarding email. It
greets the recipient by name, summarises why they received the email,
exposes the primary CTA, and reproduces the CTA URL as plain text
underneath in case the button does not survive a link-rewriting proxy.
The template is intentionally minimal — it covers the most common
post-signup flow. Consumers needing additional structure (feature
highlights, social-proof imagery, multi-step onboarding) should compose
their own template using the same components.
## Features
- Themed heading + body copy + primary CTA + plain-URL fallback +
divider + footer.
- Configurable `ctaLabel` (defaults to `"Get Started"`).
- Configurable `companyName` (defaults to `theme.branding.companyName`).
- Subject function: `Welcome to ${companyName}!`.
- Optional preview text via the `previewText` prop.
## Usage: Basic
```ts
import { renderToHtml } from "@suaveplan/email-templates/renderer";
import { welcomeTemplate } from "@suaveplan/email-templates/templates";
const { html, text, subject } = renderToHtml(welcomeTemplate, {
recipientName: "Jane",
ctaUrl: "https://app.example.test/get-started",
});
```
## Usage: Advanced
Brand-customised with a preview text and a non-default CTA label:
```ts
import { renderToHtml } from "@suaveplan/email-templates/renderer";
import { createTheme } from "@suaveplan/email-templates/theme";
import { welcomeTemplate } from "@suaveplan/email-templates/templates";
const theme = createTheme({
colors: { primary: "#22d3ee" },
branding: { companyName: "Acme Corp" },
});
const result = renderToHtml(
welcomeTemplate,
{
recipientName: "Jane",
ctaUrl: "https://acme.test/welcome",
ctaLabel: "Continue",
previewText: "You're in! Let's get you set up.",
},
{ theme },
);
```
## API Reference
| Symbol | Kind | Description |
| ------------------ | -------- | ---------------------------------------------------- |
| `welcomeTemplate` | const | `EmailTemplate<WelcomeProps>`. |
| `WelcomeEmail` | function | Direct component access (returns a `VNode`). |
| `WelcomeProps` | type | `{ recipientName, ctaUrl, ctaLabel?, companyName?, previewText? }` |
## Implementation Notes
The plain-URL fallback paragraph is rendered as a muted caption — small
type, low contrast — so it doesn't compete with the CTA visually. The
fallback line uses the same `ctaUrl` value as the button, so any later
URL change touches one source of truth.
The footer's clock is intentionally not threaded through this template
— consumers wanting a deterministic copyright year supply a `clock` to
`renderToHtml`, which passes it to the component as part of the merged
props bag.

View File

@@ -0,0 +1,89 @@
# theme
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides the theme tokens consumed by every email component — colours,
typography, spacing, branding, border-radius, and max-width. Themes are
immutable plain objects: `createTheme` returns a fresh object every call
and never mutates the shipped `defaultTheme`, so the module is safe to
import in cross-tenant contexts where two consumers compose distinct
themes side-by-side.
The default palette is `#2563eb` primary (Tailwind blue-600), a slate
secondary, and a 600 px max-width — sane email-client defaults that
render correctly in Gmail, Apple Mail, Outlook 2016+, and the major web
clients without further customisation.
## Features
- `defaultTheme`: frozen default `EmailTheme` instance.
- `createTheme(options?)`: deep-merges `options` into `defaultTheme`,
returning a new object.
- `mergeThemes(base, override)`: shallow-merges each section into `base`.
- Every section is independently overridable — partial colour overrides
leave other colours intact.
## Usage: Basic
```ts
import { createTheme } from "@suaveplan/email-templates/theme";
const theme = createTheme({
colors: { primary: "#ff6600" },
branding: { companyName: "Acme Corp" },
});
```
## Usage: Advanced
Compose a per-locale theme by layering region-specific tokens onto a base:
```ts
import { createTheme, mergeThemes } from "@suaveplan/email-templates/theme";
const base = createTheme({
branding: { companyName: "Acme" },
typography: {
fontFamily: "'Inter', sans-serif",
},
});
const jaTheme = mergeThemes(base, {
typography: { fontFamily: "'Noto Sans JP', sans-serif" },
});
```
`mergeThemes` differs from `createTheme` in that it merges into the
explicitly-supplied `base` (which may itself differ from `defaultTheme`)
— useful when a multi-tenant pipeline pre-builds a per-tenant base and
then layers a per-locale override on top.
## API Reference
| Symbol | Kind | Description |
| -------------- | -------- | ---------------------------------------------------------- |
| `defaultTheme` | const | The shipped default `EmailTheme`. |
| `createTheme` | function | `(options?) => EmailTheme` — overrides applied to default. |
| `mergeThemes` | function | `(base, override) => EmailTheme`. |
Types: `EmailTheme`, `EmailThemeColors`, `EmailThemeTypography`,
`EmailThemeSpacing`, `EmailThemeBranding`, `EmailThemeOptions`.
## Implementation Notes
Token defaults are declared as module-local `const … as const`
expressions so they cannot be re-bound; the exported `defaultTheme` is a
spread of those tokens. The exported value is intentionally **not**
`Object.freeze`'d — TypeScript's `readonly` already prevents accidental
re-assignment at the type level, and freezing would interact badly with
serialise / hydrate flows in downstream tests.
Spread-merging means deep nested fields cannot be partially overridden
within the same `createTheme` call (e.g. you cannot override one branding
field without losing the others) — each section is treated as a single
unit. That matches upstream behaviour exactly; consumers who need a
deeper merge can compose two `createTheme` calls with `mergeThemes`.

View File

@@ -0,0 +1,101 @@
# vnode
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the universal VNode primitive — a `{ tag, attrs, children }`
triple plus a pure-string HTML renderer — that lets `@suaveplan/email-templates`
deliver the full upstream component surface without any React or DOM runtime
dependency. Components emit VNode trees; the renderer is the single point at
which children are HTML-escaped and attributes are serialised, removing the
class of XSS bugs introduced when escaping is sprinkled per-component.
The module is the structural twin of `react-dom/server.renderToStaticMarkup`
restricted to the HTML subset email clients accept: every element is one of
the standard tags, every attribute is a primitive or a single `style` object,
and the renderer always closes opened tags (no fragment / portal / boundary
machinery).
## Features
- `h(tag, attrs, children, options?)` hyperscript factory with automatic
self-closing detection for void HTML elements (`img`, `br`, `hr`, `meta`,
`link`, `input`, `area`, `base`, `col`, `embed`, `source`, `track`, `wbr`).
- `renderVNode(node)` walks the tree producing email-safe HTML, escaping text
with the five-metacharacter HTML rule (`&<>"'`) and attribute values with
the four-character attribute rule (`&"<>`).
- `serialiseStyle(obj)` converts `{ borderRadius: '6px' }` to
`border-radius:6px`, kebab-casing camelCase keys and omitting `undefined`.
- `rawHtml(html)` escape hatch for the MSO conditional comment block emitted
by `wrapInDocument`. Children are echoed verbatim — never pass user input.
- `renderNodes(list)` concatenates a node list (used by the renderer).
## Usage: Basic
```ts
import { h, renderVNode } from "@suaveplan/email-templates/vnode";
const html = renderVNode(
h("p", { style: { color: "red" } }, "Hello, world"),
);
// '<p style="color:red">Hello, world</p>'
```
## Usage: Advanced
Compose a full email body without React; `renderVNode` accepts arbitrary
nesting, conditional children (via array entries with `undefined` / `null`
/ `false` filtered out), and a numeric or boolean attribute set.
```ts
import { h, renderVNode } from "@suaveplan/email-templates/vnode";
const showFooter = true;
const layout = h("table", { role: "presentation", cellpadding: 0 }, [
h("tr", {}, h("td", { style: { padding: "16px" } }, "Hi Jane,")),
showFooter && h("tr", {}, h("td", { align: "center" }, "Footer")),
]);
renderVNode(layout);
```
The renderer never inspects globals, never touches `process.env`, and never
calls `Date.now()` — it runs identically in Bun, Node, and the browser.
## API Reference
| Symbol | Kind | Description |
| ------------------ | -------- | ----------------------------------------------------------------- |
| `h` | function | Hyperscript factory returning a `VNode`. |
| `renderVNode` | function | Walks a node or text, returning HTML. |
| `renderNodes` | function | Renders a list of nodes/strings as concatenated HTML. |
| `escapeHtml` | function | HTML text-content escape (`&<>"'`). |
| `escapeAttr` | function | HTML attribute-value escape (`&"<>`). |
| `serialiseStyle` | function | CSS object → inline string with kebab-cased keys. |
| `rawHtml` | function | Escape-hatch raw-HTML embed for trusted internal fragments. |
| `VNode` | type | The tree-node interface. |
| `EmailNode` | type | `VNode \| string`. |
| `EmailChildren` | type | Children accepted by `h(...)`. |
| `StyleObject` | type | Style attribute shape. |
| `AttrValue` | type | Permitted attribute value type. |
## Implementation Notes
The renderer deliberately serialises `style` objects with the **input**
key order (insertion order, which is stable in modern JS engines). It does
not sort or dedupe — components are expected to emit a single `style`
object per element. CSS property values are coerced via `String(v)`, so
numeric pixel-less values (e.g. `lineHeight: 1.5`) survive the round-trip
intact.
The `__raw__` tag is reserved for internal use; if a component were to
emit `h("__raw__", ...)` directly, the renderer would treat it the same as
`rawHtml(...)` — children are emitted verbatim. The factory does NOT
expose this tag publicly because it bypasses escaping.
Self-closing detection is a tag-name set lookup; the lookup is
case-insensitive (`Img` → `img`). For non-HTML emitters the caller may
override via the fourth argument.