From 45c31936d1fb47a6a5ab0fa148ef1d15a4114e48 Mon Sep 17 00:00:00 2001 From: Arthur Tkachenko Date: Sun, 4 Oct 2026 04:45:58 +0000 Subject: [PATCH] Add DisplayError and a central message catalog. Package-owned rejects now throw DisplayError with a stable code. Slot and missing-shell messages are built in src/runtime/errors.ts. Render and validation failures stay RenderError. --- .github/copilot-instructions.md | 2 +- AGENTS.md | 3 +- llms-full.txt | 2 +- src/index.ts | 3 ++ src/runtime/api.ts | 10 +++--- src/runtime/errors.ts | 59 +++++++++++++++++++++++++++++++++ src/runtime/render.ts | 11 +++++- src/templates/email/shell.ts | 3 +- test/email.test.ts | 10 +++++- test/runtime.test.ts | 15 +++++++++ 10 files changed, 106 insertions(+), 12 deletions(-) create mode 100644 src/runtime/errors.ts diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index b29c4de..0d719dd 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -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//.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 `` and a leading ``. +Fragment helpers stay `(props?) => string` and live one per folder under `src/templates//.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 `` and a leading ``. diff --git a/AGENTS.md b/AGENTS.md index 42c5aed..0df3121 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 @@ -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 `` 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. diff --git a/llms-full.txt b/llms-full.txt index 22ebf4b..b0c0c57 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -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 "" returned , expected a string`). A slot that is not a string or function throws `TypeError` (`slot "" 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 "" returned , expected a string`). A slot that is not a string or function throws `DisplayError` code `slot_type` (`slot "" is null|number|..., expected a string or function`). The part that called `slot` is the part recorded on the trace. ## renderMany(jobs, options?): RenderResult[] diff --git a/src/index.ts b/src/index.ts index 8dbca7d..7d203fe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,12 @@ export { defineTemplate, + displayErrors, hasSlot, renderMany, renderTemplate, slot, + DisplayError, + DisplayErrorCode, RenderError, } from './runtime/render'; export type { diff --git a/src/runtime/api.ts b/src/runtime/api.ts index 638db34..2432b64 100644 --- a/src/runtime/api.ts +++ b/src/runtime/api.ts @@ -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 @@ -40,15 +42,11 @@ export function slot(ctx: RenderContext, 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. */ diff --git a/src/runtime/errors.ts b/src/runtime/errors.ts new file mode 100644 index 0000000..6609b30 --- /dev/null +++ b/src/runtime/errors.ts @@ -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>; + + constructor( + code: DisplayErrorCode, + message: string, + details: Readonly> = {}, + ) { + 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; diff --git a/src/runtime/render.ts b/src/runtime/render.ts index 7b8efd5..a9076fa 100644 --- a/src/runtime/render.ts +++ b/src/runtime/render.ts @@ -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'; diff --git a/src/templates/email/shell.ts b/src/templates/email/shell.ts index 34a646b..fb77d00 100644 --- a/src/templates/email/shell.ts +++ b/src/templates/email/shell.ts @@ -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'; @@ -8,7 +9,7 @@ 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}"`); + throw displayErrors.shellPartMissing(id); } return html; } diff --git a/test/email.test.ts b/test/email.test.ts index ba20432..1d7c175 100644 --- a/test/email.test.ts +++ b/test/email.test.ts @@ -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'; @@ -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"'); + } }); }); diff --git a/test/runtime.test.ts b/test/runtime.test.ts index 88df690..9da1c78 100644 --- a/test/runtime.test.ts +++ b/test/runtime.test.ts @@ -5,6 +5,8 @@ import { hasSlot, renderMany, renderTemplate, + DisplayError, + DisplayErrorCode, RenderError, slot, } from '../src/index'; @@ -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); + } }); });