Skip to content

Architecture: the generic rendering engine

The worker used to carry a hardcoded route table and one orchestrator module per page type. That is gone from the request path. Today a single data-driven engine reads page descriptors from the site's settings and renders each page either through a small set of named builders or through one generic, code-free path.

Source: src/engine/render.ts, src/engine/generic.ts, src/engine/settings.ts, src/layout/index.ts, src/types.ts.

From hardcoded pages to descriptors

A page descriptor is a declarative record served inside settings.site_settings.page_types. Its shape (PageDescriptor in src/types.ts) declares everything the engine needs to render a URL:

export interface PageDescriptor {
  type: string;
  variant?: string;
  routes: string[];
  layout: string[];
  ttl: number;
  sources?: SourceCall[];
  bindings?: Record<string, string>;
  grids?: Record<string, string>;
  sidebar?: { source?: string; fallback?: string };
  partial?: { grid?: string; source?: string };
  seo?: { from?: string };
  transforms?: string[];
  builder?: string;
  position?: number;
}

routes are the URL patterns the descriptor answers (see Routing), layout lists the content element-type slots to render, ttl is the page's cache lifetime (see Caching), and builder -- when present -- names a hardcoded renderer. Descriptors come from the CMS API; when the API serves none, the worker uses a built-in fallback set (DEFAULT_PAGE_TYPES).

The key property: a new simple page type is pure configuration. A descriptor with no builder is handled entirely by the generic path, so adding a page does not require a worker deploy. The named builders remain only for the handful of pages whose rendering logic is genuinely page-specific.

Dispatch: renderPage

Once the router has chosen a descriptor (and captured any path params), renderPage (src/engine/render.ts) dispatches on descriptor.builder:

export async function renderPage(ctx: PageCtx, match: DescriptorMatch): Promise<Response> {
  const { descriptor } = match;
  switch (descriptor.builder) {
    case "homepage":
      return homepage.render(ctx);
    case "article":
      return article.render(ctx);
    case "category":
      return category.render(ctx);
    case "author":
      return author.render(ctx);
    case "authors":
      return authors.render(ctx);
    case "sitemap":
      return sitemap.render(ctx);
    case "notfound":
      return notFound.render(ctx);
    case "static": {
      const elementType = (descriptor.layout?.[0] ?? "contact_us") as PageType;
      return renderStatic(ctx, elementType);
    }
    default:
      return renderGeneric(ctx, descriptor, match.params);
  }
}
  • The named builders (homepage, article, category, author, authors, sitemap, notfound) are the proven page renderers in src/pages/*.ts, now reached only through the engine. The old hardcoded route table and PAGE_RENDERERS map no longer exist on the request path.
  • The static builder is a thin shared renderer for fixed content pages (contact-us, about, privacy-policy, terms-of-use, do-not-sell); the content slot to render is the descriptor's first layout entry.
  • Any descriptor with no builder falls to the default case and is rendered by renderGeneric -- the code-free path.

The page context: PageCtx

Every builder receives the same PageCtx (src/types.ts), assembled in src/index.ts before dispatch:

export interface PageCtx {
  settings: SiteSettings;
  params: Record<string, string>;
  env: SiteEnv;
  request: Request;
  executionCtx: ExecutionContext;
  preview: boolean;
  version: string | null;
  cmsFetch: CmsFetch;
}

params carries the captured route segments (for example { slug: "foo" }), settings is the resolved site settings, and cmsFetch is the authenticated CMS client (see Caching). Builders read their slug or category from ctx.params exactly as the old per-page modules did.

The generic declarative path: renderGeneric

For a descriptor with no builder, renderGeneric (src/engine/generic.ts) runs the descriptor as data. There is no per-page code involved:

  1. Fetch sources. fetchSources executes the descriptor's declared sources (each a typed SourceCall against the CMS API) and binds the results under their bind keys.
  2. Apply bindings. bindings map a context key to a dotted path inside the fetched data, so templates can read a stable name regardless of the upstream response shape.
  3. Render the layout slots. Each entry in descriptor.layout is rendered as a named template against the combined site context plus bound data.
  4. Fill grids. grids maps a data-grid="..." placeholder in the HTML to a bound list; matching article cards are injected.
  5. Fill the sidebar. When descriptor.sidebar.source is set, the sidebar slot is filled from that source (with an optional fallback).
  6. Derive SEO. computeSeo reads title, description, and canonical from the descriptor's seo.from node (page-level) or the site defaults.

The response is wrapped by renderLayout and stamped with a cache-control header derived from the descriptor's ttl:

return new Response(html, {
  status: 200,
  headers: {
    "content-type": "text/html; charset=utf-8",
    "cache-control": `public, s-maxage=${ttl}, stale-while-revalidate=${ttl * 2}`,
  },
});

The layout wrapper: renderLayout

renderLayout (src/layout/index.ts) wraps a page's content slots in the full HTML document: the <head> (title, description, Open Graph and Twitter meta, canonical, favicon, JSON-LD), the rendered header and footer named templates, the content, and two small injected runtimes -- a mobile navigation toggle and a client-side "load more" pagination handler that any template gets for free by emitting a [data-load-more] button.

The content slots (layout) hold only page content; renderLayout always supplies the header and footer, so descriptors never list them.

The Handlebars-like template renderer: renderTemplate

Template HTML is stored in the CMS (per element-type, per template) and rendered by a minimal Handlebars-like engine, renderTemplate (src/layout/index.ts). It is not full Handlebars -- it implements exactly the subset the CMS templates use:

Syntax Meaning
{{var}}, {{a.b.c}} Dotted lookup, HTML-escaped by default.
{{{var}}} Triple-stash: raw (unescaped) HTML.
{{#if path}} ... {{/if}} Render the body when the value is truthy.
{{#unless path}} ... {{/unless}} Render the body when the value is falsy.
{{#each list}} ... {{/each}} Iterate an array; exposes this, @index, @first, @last.
{{#with obj}} ... {{/with}} Narrow the context to an object (or value).

The _html raw exception

Double-stash output is HTML-escaped by default, with one deliberate exception: any path whose final segment ends in _html, or is exactly html_content, is emitted raw. This matches CMS templates that ship pre-built fragments such as {{pagination.next_html}} or {{header.html_content}}:

const lastSeg = (path.split(".").pop() ?? "").trim();
const isHtmlField = /_html$|^html_content$/.test(lastSeg);
return isHtmlField ? String(v) : escapeHtml(String(v));

So {{title}} is escaped, but {{body_html}} is trusted as raw HTML. This is the contract templates rely on; treat any new *_html field as trusted CMS output.

Why this matters

The engine replaced a per-page-type codebase with one interpreter over data. The practical consequences:

  • A new content page is a descriptor in the CMS, with no builder, served through renderGeneric -- no worker code, no deploy.
  • Routing is data, so a site's URL structure can change without a redeploy (see Routing).
  • Styling is data, served by version from the CMS, so a redesign is a CSS change plus a version bump (see CSS override).

The remaining named builders exist only where the rendering logic is irreducibly page-specific.