Skip to content

render template must have a separated file #12

Closed

Description

@atherdon

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:

  1. The public renderTemplate method — validate a template, run each part, collect a trace, call compose, return a RenderResult.
  2. Supporting public API — defineTemplate, renderMany, slot, hasSlot, RenderError.
  3. 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:

  • renderTemplate
  • RenderError
  • all helpers currently in render.ts that are only used by renderTemplate (finish, createContext, assertTemplate, assertPart, normalizeHtml, okTrace, skippedTrace, describeError, publish)

The following stay in template-runtime-display:

  • defineTemplate — definition-time validation, belongs with the template model
  • renderMany — 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

  • renderTemplate is the only function that owns a render loop, error policy, and trace accumulation. That's a coherent unit.
  • defineTemplate is about validating a template definition — a different lifecycle moment (define-time vs. render-time).
  • slot / hasSlot are 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 land

Types 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.ts so the file name matches the export. This is the only rename that affects imports.
  • Keep the exported symbol named renderTemplate (no breaking rename).
  • RenderError keeps 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 onError semantics ('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 / undefined returned 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 renderTemplate into the new package.
  • Add at minimum:
    • happy path: single part, multiple parts, empty part
    • compose throws in both 'throw' and 'collect' modes
    • part throws in both modes, including trace rows for skipped parts
    • defineTemplate rejects: empty id, empty part id, reserved part id, duplicate part id, missing compose
    • slot / hasSlot behaviour: missing, empty string, function returning '', function returning non-string
    • freeze checks: caller mutating template.parts after defineTemplate has no effect
  • Keep tests in template-runtime-display for defineTemplate, slot, hasSlot, and types.

6. Downstream updates

  • template-runtime-display/src/index.ts re-exports renderTemplate and RenderError from 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 imports renderTemplate, 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-display gets a minor bump, not a major one — the re-exports preserve the old path.
  • After the next template-runtime-display major, drop the deprecated re-exports.

Out of scope

  • Rewriting renderTemplate internals.
  • Changing the Template / TemplatePart types.
  • Introducing async rendering.
  • Adding caching or memoization.
  • Renaming RenderError or 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.
  • renderTemplate and RenderError are importable from the new package.
  • template-runtime-display re-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 onError handling, trace contents, or error messages.
  • PR description states where the shared types live and why.

Open questions (resolve during implementation, note in PR)

  1. Final package name: @llazyemail/render-template vs. @llazyemail/template-render vs. keeping it under the runtime's scope?
  2. Do types move to a third @llazyemail/template-types package, or does the renderer depend on template-runtime-display for types?
  3. Does renderMany move with renderTemplate? It's a thin wrapper, but it depends on RenderJob and RenderOptions. Moving it keeps the "rendering" surface together; leaving it keeps template-runtime-display as the batching entry.
  4. Does RenderError move to its own file in the new package, or stay co-located with renderTemplate.ts?

Suggested commit plan

  1. chore(render-template): scaffold package skeleton — no logic, just files, package.json, tsconfig.json.
  2. refactor(render-template): move renderTemplate + RenderError verbatim — no behaviour change, tests still red/green as before.
  3. refactor(render-template): extract helpers into helpers/* — only if you choose the split; keeps diffs reviewable.
  4. test(render-template): port and expand renderTemplate coverage.
  5. feat(template-runtime-display): re-export renderTemplate + RenderError from new package — downstream compatibility.
  6. 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

  1. atherdon commented on Oct 3, 2026

    @atherdon
    ContributorAuthor
  2. self-assigned this
    on Oct 5, 2026
  3. added theissue type on Oct 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions