diff --git a/AGENTS.md b/AGENTS.md
index 07460a4..42c5aed 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -23,7 +23,7 @@ npm run smoke # load the ESM, CJS, and IIFE builds; requires dist/
npm run check # format, lint, typecheck, build, test, smoke, publint
```
-Node.js **24+**. Run `npm run check` before handing work back. Run `npm run test:coverage` when you add or change an executable `src/` file. `src/index.ts` and `src/runtime/types.ts` are excluded: v8 reports 0% on files with no runtime code.
+Node.js **24+**. Run `npm run check` before handing work back. Run `npm run test:coverage` when you add or change an executable `src/` file. `src/index.ts`, `src/runtime/types.ts`, and `src/templates/email/types.ts` are excluded: v8 reports 0% on files with no runtime code.
ESLint and Prettier both run in CI. Tests are Vitest, not Jest. `markup-generator` is a devDependency used to write rendered HTML in `test/write.test.ts` and `npm run generate`. It is not a runtime dependency.
@@ -37,6 +37,8 @@ src/runtime/renderTemplate.ts renderTemplate from @llazyemail/render-templat
src/runtime/api.ts defineTemplate, renderMany, slot, hasSlot, RenderError
src/runtime/explain.ts explain()
src/templates/email/email.ts defineEmailTemplate, renderEmail
+src/templates/email/shell.ts shell parts and compose
+src/templates/email/types.ts EmailProps, EmailTemplate
src/templates/head/head.ts head()
src/templates/main/main.ts main()
src/templates/footer/footer.ts footer()
@@ -44,6 +46,7 @@ src/templates/body/body.ts body()
src/templates/content/content.ts content()
src/templates/document/document.ts document()
src/utils/escape.ts escapeHtml()
+src/utils/pickDefined.ts drop omitted string fields
test/runtime.test.ts runtime spec
test/email.test.ts email preset spec
test/index.test.ts fragment spec
diff --git a/src/templates/email/email.ts b/src/templates/email/email.ts
index aab6fe8..db44a4a 100644
--- a/src/templates/email/email.ts
+++ b/src/templates/email/email.ts
@@ -1,31 +1,13 @@
-import { document } from '../document/document';
import { footer } from '../footer/footer';
import { head } from '../head/head';
import { main } from '../main/main';
-import { defineTemplate, hasSlot, renderTemplate, slot } from '../../runtime/render';
-import type { RenderOptions, RenderResult, Template, TemplatePart } from '../../runtime/types';
+import { defineTemplate, renderTemplate } from '../../runtime/render';
+import type { RenderOptions, RenderResult, Template } from '../../runtime/types';
+import { pickDefined } from '../../utils/pickDefined';
+import { composeEmail, shellPart } from './shell';
+import type { EmailProps, EmailTemplate } from './types';
-/** Flat props for the built-in email shell. Every field is optional. */
-export interface EmailProps {
- title?: string;
- preview?: string;
- heading?: string;
- bodyText?: string;
- ctaLabel?: string;
- ctaUrl?: string;
- companyName?: string;
- unsubscribeUrl?: string;
-}
-
-/**
- * An email-shaped template. `parts` are custom steps inserted inside `
`
- * after `main` and before `footer`. Built-in `head`, `main`, and `footer`
- * stay in place. A slot with the same id replaces that built-in part.
- */
-export interface EmailTemplate {
- id: string;
- parts?: readonly TemplatePart[];
-}
+export type { EmailProps, EmailTemplate } from './types';
/** Build a `Template` from the email shell plus custom parts. */
export function defineEmailTemplate(input: EmailTemplate): Template {
@@ -33,51 +15,30 @@ export function defineEmailTemplate(input: EmailTemplate): Template
return defineTemplate({
id: input.id,
parts: [
- {
- id: 'head',
- render: (ctx) => {
- if (hasSlot(ctx, 'head')) return slot(ctx, 'head');
- return head(pickDefined({ title: ctx.props.title, preview: ctx.props.preview }));
- },
- },
- {
- id: 'main',
- render: (ctx) => {
- if (hasSlot(ctx, 'main')) return slot(ctx, 'main');
- return main(
- pickDefined({
- heading: ctx.props.heading,
- bodyText: ctx.props.bodyText,
- ctaLabel: ctx.props.ctaLabel,
- ctaUrl: ctx.props.ctaUrl,
- }),
- );
- },
- },
+ shellPart('head', (ctx) =>
+ head(pickDefined({ title: ctx.props.title, preview: ctx.props.preview })),
+ ),
+ shellPart('main', (ctx) =>
+ main(
+ pickDefined({
+ heading: ctx.props.heading,
+ bodyText: ctx.props.bodyText,
+ ctaLabel: ctx.props.ctaLabel,
+ ctaUrl: ctx.props.ctaUrl,
+ }),
+ ),
+ ),
...extras,
- {
- id: 'footer',
- render: (ctx) => {
- if (hasSlot(ctx, 'footer')) return slot(ctx, 'footer');
- return footer(
- pickDefined({
- companyName: ctx.props.companyName,
- unsubscribeUrl: ctx.props.unsubscribeUrl,
- }),
- );
- },
- },
+ shellPart('footer', (ctx) =>
+ footer(
+ pickDefined({
+ companyName: ctx.props.companyName,
+ unsubscribeUrl: ctx.props.unsubscribeUrl,
+ }),
+ ),
+ ),
],
- compose: (parts) => {
- // Read the shell before custom ids. Key order is the part list.
- const headHtml = readPart(parts, 'head');
- const mainPiece = readPart(parts, 'main');
- const extraIds = Object.keys(parts).filter((id) => !SHELL_PARTS.has(id));
- const extraHtml = extraIds.map((id) => readPart(parts, id)).join('\n');
- const footerHtml = readPart(parts, 'footer');
- const mainHtml = extraIds.length > 0 ? `${mainPiece}\n${extraHtml}` : mainPiece;
- return document({ headHtml, mainHtml, footerHtml });
- },
+ compose: (parts) => composeEmail(parts),
});
}
@@ -94,26 +55,3 @@ export function renderEmail(
function isTemplate(value: EmailTemplate | Template): value is Template {
return 'compose' in value && typeof value.compose === 'function';
}
-
-const SHELL_PARTS = new Set(['head', 'main', 'footer']);
-
-function readPart(parts: Readonly>, id: string): string {
- const html = parts[id];
- if (typeof html !== 'string') {
- throw new TypeError(`email shell is missing part "${id}"`);
- }
- return html;
-}
-
-function pickDefined>(
- values: T,
-): {
- [K in keyof T]?: string;
-} {
- const picked: { [K in keyof T]?: string } = {};
- for (const key of Object.keys(values) as (keyof T)[]) {
- const value = values[key];
- if (typeof value === 'string') picked[key] = value;
- }
- return picked;
-}
diff --git a/src/templates/email/shell.ts b/src/templates/email/shell.ts
new file mode 100644
index 0000000..34a646b
--- /dev/null
+++ b/src/templates/email/shell.ts
@@ -0,0 +1,37 @@
+import { document } from '../document/document';
+import { hasSlot, slot } from '../../runtime/render';
+import type { RenderContext, TemplatePart } from '../../runtime/types';
+import type { EmailProps } from './types';
+
+const SHELL_PARTS = new Set(['head', 'main', 'footer']);
+
+function readPart(parts: Readonly>, id: string): string {
+ const html = parts[id];
+ if (typeof html !== 'string') {
+ throw new TypeError(`email shell is missing part "${id}"`);
+ }
+ return html;
+}
+
+/** Built-in shell step. A slot with the same id replaces the fragment. */
+export function shellPart(
+ id: string,
+ render: (ctx: RenderContext) => string,
+): TemplatePart {
+ return {
+ id,
+ render: (ctx) => (hasSlot(ctx, id) ? slot(ctx, id) : render(ctx)),
+ };
+}
+
+/** Place custom part HTML inside ``, after `main` and before `footer`. */
+export function composeEmail(parts: Readonly>): string {
+ // Read the shell before custom ids. Key order is the part list.
+ const headHtml = readPart(parts, 'head');
+ const mainPiece = readPart(parts, 'main');
+ const extraIds = Object.keys(parts).filter((id) => !SHELL_PARTS.has(id));
+ const extraHtml = extraIds.map((id) => readPart(parts, id)).join('\n');
+ const footerHtml = readPart(parts, 'footer');
+ const mainHtml = extraIds.length > 0 ? `${mainPiece}\n${extraHtml}` : mainPiece;
+ return document({ headHtml, mainHtml, footerHtml });
+}
diff --git a/src/templates/email/types.ts b/src/templates/email/types.ts
new file mode 100644
index 0000000..fa8168b
--- /dev/null
+++ b/src/templates/email/types.ts
@@ -0,0 +1,23 @@
+import type { TemplatePart } from '../../runtime/types';
+
+/** Flat props for the built-in email shell. Every field is optional. */
+export interface EmailProps {
+ title?: string;
+ preview?: string;
+ heading?: string;
+ bodyText?: string;
+ ctaLabel?: string;
+ ctaUrl?: string;
+ companyName?: string;
+ unsubscribeUrl?: string;
+}
+
+/**
+ * An email-shaped template. `parts` are custom steps inserted inside ``
+ * after `main` and before `footer`. Built-in `head`, `main`, and `footer`
+ * stay in place. A slot with the same id replaces that built-in part.
+ */
+export interface EmailTemplate {
+ id: string;
+ parts?: readonly TemplatePart[];
+}
diff --git a/src/utils/pickDefined.ts b/src/utils/pickDefined.ts
new file mode 100644
index 0000000..6e529d3
--- /dev/null
+++ b/src/utils/pickDefined.ts
@@ -0,0 +1,13 @@
+/** Drop omitted fields so callers can pass the result under exactOptionalPropertyTypes. */
+export function pickDefined>(
+ values: T,
+): {
+ [K in keyof T]?: string;
+} {
+ const picked: { [K in keyof T]?: string } = {};
+ for (const key of Object.keys(values) as (keyof T)[]) {
+ const value = values[key];
+ if (typeof value === 'string') picked[key] = value;
+ }
+ return picked;
+}
diff --git a/vitest.config.ts b/vitest.config.ts
index 6adf843..6b26b4d 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -9,7 +9,7 @@ export default defineConfig({
provider: 'v8',
reporter: ['text', 'html'],
include: ['src/**/*.ts'],
- exclude: ['src/index.ts', 'src/runtime/types.ts'],
+ exclude: ['src/index.ts', 'src/runtime/types.ts', 'src/templates/email/types.ts'],
thresholds: {
statements: 100,
branches: 100,