Repository navigation
Description
Task: Extract renderTemplate method from template-runtime-display into a standalone package
Context
template-runtime-display currently ships a single entry file at src/runtime/render.ts that mixes three concerns:
- The public
renderTemplatemethod — validate a template, run each part, collect a trace, callcompose, return aRenderResult. - Supporting public API —
defineTemplate,renderMany,slot,hasSlot,RenderError. - Internal helpers —
finish,createContext,assertTemplate,assertPart,normalizeHtml,okTrace,skippedTrace,describeError,publish.
The goal is to extract the renderTemplate method (and its direct supporting helpers) into its own package so that:
- The runtime package stays focused on types and template definitions.
- The rendering engine can be versioned, tested, and consumed independently.
- Future templates (e.g. Postmark ones in
postmark-transactional-template-simple) can depend on the renderer without pulling in unrelated runtime surface.
This is a move, not a rewrite. Public function signatures, error types, and rendered output must stay byte-identical.
Goal
Create a new package — working name @llazyemail/render-template (confirm final name) — that owns:
renderTemplateRenderError- all helpers currently in
render.tsthat are only used byrenderTemplate(finish,createContext,assertTemplate,assertPart,normalizeHtml,okTrace,skippedTrace,describeError,publish)
The following stay in template-runtime-display:
defineTemplate— definition-time validation, belongs with the template modelrenderMany— batch orchestration; may move if it makes the split cleaner (decide during implementation)slot,hasSlot— context accessors used by parts, not by the renderer- all types from
./types(Template,TemplatePart,RenderContext,RenderOptions,RenderResult,PartTrace,PartFailure,SlotValue,RenderJob)
Why this shape
renderTemplateis the only function that owns a render loop, error policy, and trace accumulation. That's a coherent unit.defineTemplateis about validating a template definition — a different lifecycle moment (define-time vs. render-time).slot/hasSlotare called by template parts, not by the renderer. Keeping them in the runtime means custom parts can be written without depending on the renderer package.- Types must stay in one place so both packages (and the future Postmark project) import from a single source of truth.
Deliverables
1. New package skeleton
packages/render-template/
src/
renderTemplate.ts ← moved, renamed file (see #3)
errors.ts ← RenderError extracted
helpers/
finish.ts
context.ts
assertTemplate.ts
normalizeHtml.ts
trace.ts ← okTrace, skippedTrace, publish
describeError.ts
index.ts ← re-exports public surface
package.json
tsconfig.json
README.md
test/
renderTemplate.test.ts
errors.test.ts
If some helpers are tiny and only used in one place, it is fine to co-locate them inside renderTemplate.ts rather than splitting into helpers/. The split above is the ideal; collapse where it aids readability.
2. Public surface of the new package
// packages/render-template/src/index.ts
export { renderTemplate } from './renderTemplate';
export { RenderError } from './errors';
export type {
Template,
TemplatePart,
RenderContext,
RenderOptions,
RenderResult,
PartTrace,
PartFailure,
SlotValue,
} from 'template-runtime-display'; // or wherever types landTypes may live in a third shared package (@llazyemail/template-types) if you prefer to avoid a dependency from the renderer back into the runtime. Decide during implementation and document the choice in the PR description.
3. Naming
- Rename the file
render.ts→renderTemplate.tsso the file name matches the export. This is the only rename that affects imports. - Keep the exported symbol named
renderTemplate(no breaking rename). RenderErrorkeeps its name, fields (templateId,partId,trace,cause), and message format.
4. Behaviour parity (must not change)
- Same validation order in
assertTemplate/assertPart. - Same reserved part id (
compose). - Same
onErrorsemantics ('throw'default,'collect'opt-in). - Same trace row shape and freeze behaviour (
publish). - Same error messages, including the
template "<id>" failed at part "<id>": <msg>wording. - Same handling of
null/undefinedreturned by parts (normalized to''). - Same slot resolution rules in
slot(missing →'', function → called with same ctx, non-string →TypeError).
Any behavioural change is a separate task; do not sneak it into this move.
5. Tests
- Port any existing tests for
renderTemplateinto the new package. - Add at minimum:
- happy path: single part, multiple parts, empty part
composethrows in both'throw'and'collect'modes- part throws in both modes, including trace rows for
skippedparts defineTemplaterejects: empty id, empty part id, reserved part id, duplicate part id, missing composeslot/hasSlotbehaviour: missing, empty string, function returning'', function returning non-string- freeze checks: caller mutating
template.partsafterdefineTemplatehas no effect
- Keep tests in
template-runtime-displayfordefineTemplate,slot,hasSlot, and types.
6. Downstream updates
template-runtime-display/src/index.tsre-exportsrenderTemplateandRenderErrorfrom the new package so existing consumers keep working with no changes. Mark these re-exports as deprecated with a pointer to the new package.postmark-transactional-template-simple— if it already importsrenderTemplate, add the new package as a dependency and update imports. If it only imports the higher-level document renderer, no change needed.
7. Versioning & publish
- New package starts at
0.1.0. template-runtime-displaygets a minor bump, not a major one — the re-exports preserve the old path.- After the next
template-runtime-displaymajor, drop the deprecated re-exports.
Out of scope
- Rewriting
renderTemplateinternals. - Changing the
Template/TemplateParttypes. - Introducing async rendering.
- Adding caching or memoization.
- Renaming
RenderErroror changing its fields. - Merging with
postmark-transactional-template-simple— that project consumes this package, it does not absorb it.
Acceptance criteria
- New package exists, builds, and publishes under the agreed name.
-
renderTemplateandRenderErrorare importable from the new package. -
template-runtime-displayre-exports both, so no downstream import breaks. - Test suite in the new package passes and covers the cases listed in §5.
- A byte-for-byte snapshot of rendered HTML for at least one representative template is identical before and after the move.
- No behavioural diffs in
onErrorhandling, trace contents, or error messages. - PR description states where the shared types live and why.
Open questions (resolve during implementation, note in PR)
- Final package name:
@llazyemail/render-templatevs.@llazyemail/template-rendervs. keeping it under the runtime's scope? - Do types move to a third
@llazyemail/template-typespackage, or does the renderer depend ontemplate-runtime-displayfor types? - Does
renderManymove withrenderTemplate? It's a thin wrapper, but it depends onRenderJobandRenderOptions. Moving it keeps the "rendering" surface together; leaving it keepstemplate-runtime-displayas the batching entry. - Does
RenderErrormove to its own file in the new package, or stay co-located withrenderTemplate.ts?
Suggested commit plan
chore(render-template): scaffold package skeleton— no logic, just files,package.json,tsconfig.json.refactor(render-template): move renderTemplate + RenderError verbatim— no behaviour change, tests still red/green as before.refactor(render-template): extract helpers into helpers/*— only if you choose the split; keeps diffs reviewable.test(render-template): port and expand renderTemplate coverage.feat(template-runtime-display): re-export renderTemplate + RenderError from new package— downstream compatibility.chore: bump versions, update changelogs.
Want me to also draft the README.md for the new package (short: install, basic usage, error modes, link back to the runtime for types), or the snapshot test harness for §5? Either one is a small follow-up.
https://github.com/LLazyEmail/template-runtime-display/blob/main/src/runtime/render.ts
Activity
atherdon commentedon Oct 3, 2026
#19