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 insrc/pages/*.ts, now reached only through the engine. The old hardcoded route table andPAGE_RENDERERSmap no longer exist on the request path. - The
staticbuilder 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 firstlayoutentry. - Any descriptor with no
builderfalls to thedefaultcase and is rendered byrenderGeneric-- 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:
- Fetch sources.
fetchSourcesexecutes the descriptor's declaredsources(each a typedSourceCallagainst the CMS API) and binds the results under theirbindkeys. - Apply bindings.
bindingsmap a context key to a dotted path inside the fetched data, so templates can read a stable name regardless of the upstream response shape. - Render the layout slots. Each entry in
descriptor.layoutis rendered as a named template against the combined site context plus bound data. - Fill grids.
gridsmaps adata-grid="..."placeholder in the HTML to a bound list; matching article cards are injected. - Fill the sidebar. When
descriptor.sidebar.sourceis set, the sidebar slot is filled from that source (with an optionalfallback). - Derive SEO.
computeSeoreads title, description, and canonical from the descriptor'sseo.fromnode (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 throughrenderGeneric-- 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.