Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -37,13 +37,16 @@ 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()
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
Expand Down
118 changes: 28 additions & 90 deletions src/templates/email/email.ts
Original file line number Diff line number Diff line change
@@ -1,83 +1,44 @@
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 `<body>`
* 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<EmailProps>[];
}
export type { EmailProps, EmailTemplate } from './types';

/** Build a `Template<EmailProps>` from the email shell plus custom parts. */
export function defineEmailTemplate(input: EmailTemplate): Template<EmailProps> {
const extras = input.parts ?? [];
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),
});
}

Expand All @@ -94,26 +55,3 @@ export function renderEmail(
function isTemplate(value: EmailTemplate | Template<EmailProps>): value is Template<EmailProps> {
return 'compose' in value && typeof value.compose === 'function';
}

const SHELL_PARTS = new Set(['head', 'main', 'footer']);

function readPart(parts: Readonly<Record<string, string>>, id: string): string {
const html = parts[id];
if (typeof html !== 'string') {
throw new TypeError(`email shell is missing part "${id}"`);
}
return html;
}

function pickDefined<T extends Record<string, string | undefined>>(
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;
}
37 changes: 37 additions & 0 deletions src/templates/email/shell.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, string>>, 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<EmailProps>) => string,
): TemplatePart<EmailProps> {
return {
id,
render: (ctx) => (hasSlot(ctx, id) ? slot(ctx, id) : render(ctx)),
};
}

/** Place custom part HTML inside `<body>`, after `main` and before `footer`. */
export function composeEmail(parts: Readonly<Record<string, string>>): 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 });
}
23 changes: 23 additions & 0 deletions src/templates/email/types.ts
Original file line number Diff line number Diff line change
@@ -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 `<body>`
* 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<EmailProps>[];
}
13 changes: 13 additions & 0 deletions src/utils/pickDefined.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/** Drop omitted fields so callers can pass the result under exactOptionalPropertyTypes. */
export function pickDefined<T extends Record<string, string | undefined>>(
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;
}
2 changes: 1 addition & 1 deletion vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading