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,