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
2 changes: 1 addition & 1 deletion .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,4 @@ Vitest, `test/**/*.test.ts` only. Coverage via `@vitest/coverage-v8`. Executable

## Editing

Fragment helpers stay `(props?) => string` and live one per folder under `src/templates/<name>/<name>.ts`. Shared helpers live in `src/utils/`. Re-export from `src/index.ts`. The runtime returns results and throws `RenderError` when `onError` is `throw`. Custom code is a part or a slot, not a new registry. Omitted strings render empty. The CTA needs both label and url. Unsubscribe needs a url. Props and slots are raw HTML. `document` keeps `<html lang="en">` and a leading `<!DOCTYPE html>`.
Fragment helpers stay `(props?) => string` and live one per folder under `src/templates/<name>/<name>.ts`. Shared helpers live in `src/utils/`. Re-export from `src/index.ts`. The runtime returns results and throws `RenderError` when `onError` is `throw`. Package-owned rejects throw `DisplayError` from `src/runtime/errors.ts`. Custom code is a part or a slot, not a new registry. Omitted strings render empty. The CTA needs both label and url. Unsubscribe needs a url. Props and slots are raw HTML. `document` keeps `<html lang="en">` and a leading `<!DOCTYPE html>`.
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ src/runtime/types.ts template, context, trace, and job types
src/runtime/render.ts public re-exports
src/runtime/renderTemplate.ts renderTemplate from @llazyemail/render-template
src/runtime/api.ts defineTemplate, renderMany, slot, hasSlot, RenderError
src/runtime/errors.ts DisplayError, codes, and message catalog
src/runtime/explain.ts explain()
src/templates/email/email.ts defineEmailTemplate, renderEmail
src/templates/email/shell.ts shell parts and compose
Expand Down Expand Up @@ -77,7 +78,7 @@ Each part receives one context: `templateId`, the same props object the caller p

`renderMany` runs jobs that share one props type. Throw mode stops the batch at the first broken job, and later jobs do not run. Pass `onError: 'collect'` when one broken template must not hide the others. Jobs may omit `props` (`{}`) and `slots`.

Custom code is a part, or a slot that a part reads with `slot(ctx, id)`. A missing slot is `''`. `hasSlot` is true for an empty string. A function slot is called with the same context. `null` or `undefined` from that function is `''`. A non-string slot, or a non-string return, throws `TypeError` and the part that called `slot` is the part that fails.
Custom code is a part, or a slot that a part reads with `slot(ctx, id)`. A missing slot is `''`. `hasSlot` is true for an empty string. A function slot is called with the same context. `null` or `undefined` from that function is `''`. A non-string slot, or a non-string return, throws `DisplayError` (`slot_type` or `slot_return`) and the part that called `slot` is the part that fails. Package-owned messages are built in `src/runtime/errors.ts`. Render and validation failures stay `RenderError`.

`defineEmailTemplate` and `renderEmail` are a preset over the same runtime. Part order is `head`, `main`, the caller's parts, then `footer`. Compose places custom HTML inside `<body>` after `main` and before `footer`, joined with newlines. With no custom parts, the HTML equals `document({ headHtml: head(...), mainHtml: main(...), footerHtml: footer(...) })`. A slot named `head`, `main`, or `footer` replaces that built-in fragment. Omitted email props follow the fragment omission rules. `content()` is not in the shell. A custom part may call it.

Expand Down
2 changes: 1 addition & 1 deletion llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ One text block for logs. A `RenderError` prints `error.message` (it contains `fa

`hasSlot` uses `Object.hasOwn`. An empty string is provided. A missing slot makes `slot` return `''`.

A function slot is called with the same context. `null` and `undefined` become `''`. A non-string return throws `TypeError` (`slot "<id>" returned <type>, expected a string`). A slot that is not a string or function throws `TypeError` (`slot "<id>" is null|number|..., expected a string or function`). The part that called `slot` is the part recorded on the trace.
A function slot is called with the same context. `null` and `undefined` become `''`. A non-string return throws `DisplayError` code `slot_return` (`slot "<id>" returned <type>, expected a string`). A slot that is not a string or function throws `DisplayError` code `slot_type` (`slot "<id>" is null|number|..., expected a string or function`). The part that called `slot` is the part recorded on the trace.

## renderMany(jobs, options?): RenderResult[]

Expand Down
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
export {
defineTemplate,
displayErrors,
hasSlot,
renderMany,
renderTemplate,
slot,
DisplayError,
DisplayErrorCode,
RenderError,
} from './runtime/render';
export type {
Expand Down
10 changes: 4 additions & 6 deletions src/runtime/api.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { assertTemplate, type Template as PackageTemplate } from '@llazyemail/render-template';
import type { RenderContext, RenderJob, RenderOptions, RenderResult, Template } from './types';
import { displayErrors } from './errors';
import { renderTemplate } from './renderTemplate';

export { RenderError } from '@llazyemail/render-template';
export { DisplayError, DisplayErrorCode, displayErrors } from './errors';

/**
* Validate, copy, and freeze a template. Later edits to the caller's part
Expand Down Expand Up @@ -40,15 +42,11 @@ export function slot<TProps>(ctx: RenderContext<TProps>, id: string): string {
if (typeof value === 'function') {
const html = value(ctx);
if (html == null) return '';
if (typeof html !== 'string') {
throw new TypeError(`slot "${id}" returned ${typeof html}, expected a string`);
}
if (typeof html !== 'string') throw displayErrors.slotReturn(id, html);
return html;
}
if (typeof value === 'string') return value;
throw new TypeError(
`slot "${id}" is ${value === null ? 'null' : typeof value}, expected a string or function`,
);
throw displayErrors.slotType(id, value);
}

/** True when the caller passed this slot, including an empty string. */
Expand Down
59 changes: 59 additions & 0 deletions src/runtime/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/** Stable codes for failures this package throws itself. Render failures stay `RenderError`. */
export const DisplayErrorCode = {
slotType: 'slot_type',
slotReturn: 'slot_return',
shellPartMissing: 'shell_part_missing',
} as const;

export type DisplayErrorCode = (typeof DisplayErrorCode)[keyof typeof DisplayErrorCode];

/**
* Thrown when this package rejects a caller value before or beside rendering.
* `name` is always `DisplayError`. `code` is the stable id. `message` is the log line.
*/
export class DisplayError extends Error {
readonly code: DisplayErrorCode;
readonly details: Readonly<Record<string, string>>;

constructor(
code: DisplayErrorCode,
message: string,
details: Readonly<Record<string, string>> = {},
) {
super(message);
this.name = 'DisplayError';
this.code = code;
this.details = Object.freeze({ ...details });
}
}

function received(value: unknown): string {
return value === null ? 'null' : typeof value;
}

/** Every package-owned throw is built here so the wording stays in one place. */
export const displayErrors = {
slotType(id: string, value: unknown): DisplayError {
const kind = received(value);
return new DisplayError(
DisplayErrorCode.slotType,
`slot "${id}" is ${kind}, expected a string or function`,
{ slotId: id, received: kind },
);
},
slotReturn(id: string, value: unknown): DisplayError {
const kind = received(value);
return new DisplayError(
DisplayErrorCode.slotReturn,
`slot "${id}" returned ${kind}, expected a string`,
{ slotId: id, received: kind },
);
},
shellPartMissing(id: string): DisplayError {
return new DisplayError(
DisplayErrorCode.shellPartMissing,
`email shell is missing part "${id}"`,
{ partId: id },
);
},
} as const;
11 changes: 10 additions & 1 deletion src/runtime/render.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,11 @@
export { renderTemplate } from './renderTemplate';
export { defineTemplate, hasSlot, renderMany, slot, RenderError } from './api';
export {
defineTemplate,
hasSlot,
renderMany,
slot,
RenderError,
DisplayError,
DisplayErrorCode,
displayErrors,
} from './api';
3 changes: 2 additions & 1 deletion src/templates/email/shell.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { displayErrors } from '../../runtime/errors';
import { document } from '../document/document';
import { hasSlot, slot } from '../../runtime/render';
import type { RenderContext, TemplatePart } from '../../runtime/types';
Expand All @@ -8,7 +9,7 @@ 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}"`);
throw displayErrors.shellPartMissing(id);
}
return html;
}
Expand Down
10 changes: 9 additions & 1 deletion test/email.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { footer } from '../src/templates/footer/footer';
import { head } from '../src/templates/head/head';
import { main } from '../src/templates/main/main';
import { explain } from '../src/runtime/explain';
import { DisplayError, DisplayErrorCode } from '../src/runtime/errors';
import { defineEmailTemplate, renderEmail } from '../src/templates/email/email';
import { slot } from '../src/runtime/render';
import type { EmailTemplate } from '../src/templates/email/email';
Expand Down Expand Up @@ -140,7 +141,14 @@ describe('renderEmail', () => {
it('names the shell part when compose is called without rendered HTML', () => {
const defined = defineEmailTemplate({ id: 'letter' });
expect(() => defined.compose({}, { templateId: 'letter', props: {}, slots: {} })).toThrow(
/missing part "head"/,
DisplayError,
);
try {
defined.compose({}, { templateId: 'letter', props: {}, slots: {} });
} catch (error) {
expect(error).toBeInstanceOf(DisplayError);
expect((error as DisplayError).code).toBe(DisplayErrorCode.shellPartMissing);
expect((error as DisplayError).message).toContain('missing part "head"');
}
});
});
15 changes: 15 additions & 0 deletions test/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
hasSlot,
renderMany,
renderTemplate,
DisplayError,
DisplayErrorCode,
RenderError,
slot,
} from '../src/index';
Expand Down Expand Up @@ -226,9 +228,22 @@ describe('slots', () => {
num: () => 2 as unknown as string,
},
};
expect(() => slot(ctx, 'bad')).toThrow(DisplayError);
expect(() => slot(ctx, 'bad')).toThrow(/slot "bad" is number/);
expect(() => slot(ctx, 'nil')).toThrow(/slot "nil" is null/);
expect(() => slot(ctx, 'num')).toThrow(/slot "num" returned number/);
try {
slot(ctx, 'bad');
} catch (error) {
expect(error).toBeInstanceOf(DisplayError);
expect((error as DisplayError).code).toBe(DisplayErrorCode.slotType);
expect((error as DisplayError).name).toBe('DisplayError');
}
try {
slot(ctx, 'num');
} catch (error) {
expect((error as DisplayError).code).toBe(DisplayErrorCode.slotReturn);
}
});
});

Expand Down
Loading