Skip to content

Repository files navigation

template-server

Preview, tweak and print your HTML templates from the browser.

An Express add-on that serves your email, PDF and page templates behind a floating options panel. Declare the parameters a template understands, and the panel lets you flip through every variant with typed, URL-backed controls. One click renders the same HTML to PDF.

Quick start · Defining templates · The options panel · PDF · Routes · API · AGENTS.md


Why

Transactional emails, invoices, vouchers and reports come in dozens of variants: locale, formal or informal tone, with or without a note, paid or unpaid, one ticket or four. Checking every variant by editing query strings is slow, and nothing tells you which parameters a template actually reads.

template-server turns that into a schema. Each template declares its options once, the parsed values arrive fully typed in the render function, and the browser gets a panel that knows about every option: switches for booleans, steppers for numbers, chips for enums, a text box for strings.

  • Typed options with option.enum, option.oneOf, option.boolean, option.number, option.string and option.text. Defaults are applied server-side, so params.locale is a Locale, never string | undefined.
  • Shared option sets composed with spread. Define your branding options once and reuse them in thirty templates.
  • A shared context for translators, storage clients or fixtures, passed to every render instead of module globals.
  • Groups and kinds. Templates are grouped on the index page and tagged as email, pdf or page. The panel adapts: emails get a mobile-width preview, PDFs get a one-click render.
  • Isolated UI. The panel lives in a Shadow DOM, so your template CSS cannot break it and it cannot leak into your template. It is hidden in print and never appears in a PDF.
  • Zero build step for the client. Plain JavaScript and CSS, served from the package.

Quick start

npm install template-server express
# puppeteer is an optional peer dependency, only needed for /pdf
npm install --save-dev puppeteer
import { createTemplateServer, defineTemplate, option } from 'template-server';

const invoice = defineTemplate({
    title: 'Invoice',
    kind: 'pdf',
    options: {
        currency: option.oneOf(['EUR', 'USD', 'GBP']),
        status: option.oneOf(['draft', 'sent', 'paid']),
        items: option.number({ min: 1, max: 20, default: 3 }),
        showTax: option.boolean({ default: true }),
    },
    render: ({ currency, status, items, showTax }) => `
        <!DOCTYPE html><html><body>
            <h1>Invoice · ${status.toUpperCase()}</h1>
            ${Array.from({ length: items }, (_, i) => `<p>Item ${i + 1}: ${currency} 100.00</p>`).join('')}
            ${showTax ? '<p>VAT 20%</p>' : ''}
        </body></html>`,
});

createTemplateServer({ templates: { invoice } }).listen(3020);

Open http://localhost:3020, pick the template, and use the panel in the bottom-right corner. Every change updates the query string in place, so any variant is a shareable link.

Defining templates

Options

Every option describes one query parameter: how it is parsed, how it is serialized, what the panel renders for it, and what the value is when the URL does not set it.

Builder Type inside render Panel control URL form Default
option.enum(MyEnum) MyEnum chips member value first member
option.oneOf(['a', 'b']) 'a' | 'b' chips the literal first value
option.boolean() boolean switch 1 / 0 false
option.number({ min, max, step }) number stepper number default, else min, else 0
option.string({ placeholder }) string text input text ''
option.text({ placeholder, rows }) string textarea text ''

All builders accept label, description and default. Invalid URL values fall back to the default rather than failing.

Shared option sets

Declare option sets once with defineOptions and spread them into templates. Infer gives you the parameter type, so fixtures can take typed params too:

import { defineOptions, option, type Infer } from 'template-server';

export const branding = defineOptions({
    restaurant: option.oneOf(['Das Bootshaus', 'Tante Liesl']),
    locale: option.enum(SupportedLocale, { default: SupportedLocale.DE }),
    formal: option.boolean(),
});

export const reservation = defineOptions({
    note: option.boolean({ label: 'Guest note' }),
    price: option.oneOf(['none', 'full', 'deposit']),
    payment: option.enum(PaymentType),
});

export type BrandingParams = Infer<typeof branding>;

function createRestaurant(params: BrandingParams) { /* params.formal is a boolean */ }

const confirmation = defineTemplate({
    group: 'Reservation emails',
    kind: 'email',
    options: { ...branding, ...reservation, text: option.boolean() },
    render: (params, ctx: AppContext) => {
        const restaurant = createRestaurant(params);
        return renderEmail(ConfirmationTemplate({ restaurant, locale: params.locale, translate: ctx.translate }));
    },
});

Context

Anything your templates need at render time goes into the server context. It is merged with { template, baseUrl, query } and passed as the second argument to render. Type ctx in your render functions and the compiler checks it against what the server provides.

interface AppContext { translate: TranslateFn; storage: Storage }

createTemplateServer<AppContext>({
    templates: { confirmation, reminder, voucher },
    context: async (req) => ({ translate: await getTranslator(req), storage }),
    pdf: { printBackground: true },
    title: 'Molzait templates',
});

Groups and kinds

Field Effect
title, description Shown on the index page and in the panel header.
group Templates with the same group are listed together on the index page.
kind: 'email' Panel gets a mobile-width preview toggle (375 px). No PDF action.
kind: 'pdf' Panel and index get an "Open as PDF" action.
kind: 'page' Default. Plain preview.
pdf Puppeteer PDFOptions for this template, merged over the server defaults.

Legacy function templates

The original form still works and can be mixed with definitions:

templates: {
    legacy: (query, options) => {
        options.theme = ['light', 'dark'];
        return `<html>…${query.theme}…</html>`;
    },
}

Legacy templates render chips only, have no defaults, and receive the raw query.

The options panel

  • Collapsed pill with a badge counting the parameters currently set. Press o to toggle, Esc to collapse.
  • Labels left, controls right. Enum values as chips, booleans as switches, numbers as steppers, strings as text inputs, multi-line text as textareas.
  • The value in effect is always visible: an outlined chip means "default, not in the URL", a solid chip means "set". Clicking a set chip removes the parameter again. Values in the URL that are not in the list show as a dashed custom chip.
  • Filter box for templates with many options, plus a footer with the live query string, a copy-link button and Reset.
  • Navigation uses location.replace, so flipping options never pollutes the browser history.
  • Movable to the left or right edge. Collapsed state, side and preview width are remembered per template.
  • Rendered inside a Shadow DOM and hidden under @media print.

PDF rendering

GET /:template/pdf renders the same HTML with headless Chromium via Puppeteer and streams an A4 PDF with printBackground: true. Options are merged in this order: built-in defaults, server pdf, template pdf.

Puppeteer is a peer dependency and is loaded lazily, so projects that never render PDFs do not need it installed.

Routes

Route Description
GET / Index page: grouped, searchable list of templates with kind badges. Press / to search.
GET /:template Renders the template with the options panel injected.
GET /:template/pdf Renders the template to PDF.
GET /public/* Panel assets (options.js, options.css), versioned for cache busting.

Template names must not contain /. Use group for organisation instead.

Mounting into an existing app

import express from 'express';
import { attachTemplateServer } from 'template-server';

const app = express();
attachTemplateServer(app, { templates, context });

Asset and link URLs respect req.baseUrl, so the server also works when mounted as a sub-app.

API

createTemplateServer(config | routes, pdfDefaults?)   // returns a new Express app
attachTemplateServer(app, config | routes, pdfDefaults?)

defineTemplate({ title?, description?, group?, kind?, options?, pdf?, render })
defineOptions(schema)                                  // identity, keeps literal types
option.enum | option.oneOf | option.boolean | option.number | option.string | option.text

type Infer<typeof schema>                              // parsed params type
type TemplateContext<C>                                // C & { template, baseUrl, query }
parseParams(schema, query)                             // the parser the server uses

Development

pnpm install
npm start          # example server on http://localhost:3020 (PORT overrides)
npm run build      # compile to dist/

The example in src/start.ts shows shared option sets, a typed context, email and PDF kinds, and a legacy template side by side. See AGENTS.md for an in-depth description of the architecture written for AI coding agents.

License

MIT

About

Basic server to render templates with selectable options

Resources

Stars

0 stars

Watchers

2 watching

Forks

Releases

Packages

Used by

Contributors

Languages