Skip to content

Page types (data-driven pages)

A page_types row describes a page in data. It is the heart of the P1 data-driven rendering model: instead of one hand-written code file per page kind (router.ts, pages/article.ts, pages/homepage.ts, ...), the worker reads these rows and runs one generic loop to render any page.

This page documents every column. The plain-English walk of sources / bind / call / params -- the part that lets a non-engineer follow a real example -- is on sources / bind / call / params explained.

What one row means

A page_types row (model PageType) is one (page type, variant) that a template renders. It is scoped to a template_id -- a template owns its page types. The worker reads these rows (served inside the public /settings payload, resolved for the site's active template) and runs:

match route → fetch sources → build → wrap layout

A future task can add per-site overrides keyed by (type, variant), exactly the way site_block_overrides overrides blocks.

The unique key: (template_id, type, variant)

UniqueConstraint("template_id", "type", "variant",
                 name="uq_page_types_template_type_variant")

A template has at most one row per (type, variant). This is also what makes the seed idempotent: the seed INSERT ... ON DUPLICATE KEY UPDATE re-runs safely because a second run collides on this unique key and updates in place.

Why variant

variant lets one template carry multiple layouts for the same page type -- for example a "listicle" article alongside the "default" article. It is NOT NULL with a "default" server default so the unique key stays clean (MySQL treats NULLs as distinct, which would let duplicate "no-variant" rows slip in).

A request resolves to a type first (by route), and then the builder/content picks the variant -- the worker reads it from a content field (e.g. content.settings.layout or content_type) and falls back to "default".

Columns

Besides the shared CMSBase columns (id, created_at, updated_at, is_active):

Column Type Required What it does
template_id INT FK → templates.id, indexed yes The owning template.
type VARCHAR(64), indexed yes The page kind (e.g. homepage, article, category, author, 404).
variant VARCHAR(64), default "default" yes The layout variant within a type.
routes JSON yes (NOT NULL) List of path patterns that map a URL to this descriptor. {name} segments capture params; a trailing * is a catch-all.
layout JSON yes (NOT NULL) Ordered list of element_type content slots to render into the page body.
ttl INT, default 300 yes Cache lifetime in seconds for the rendered page.
sources JSON no List of {bind, call, params, required} declared API calls (call in lists / pages / authors / sitemap / seo).
bindings JSON no Map of template-context key → dotted path into a fetched source, e.g. {"related": "related.items"}.
grids JSON no Declarative grid fill for pure-data page types.
sidebar JSON no Declarative sidebar fill.
partial JSON no Declarative partial fill.
seo JSON no SEO hints, e.g. {"from": "page"} or {"from": "site"}.
transforms JSON no Ordered list of named editorial transforms the worker applies (e.g. article_content_blocks, editors_picks_sidebar).
builder VARCHAR(64) no Optional named builder transform for page types whose shaping is too bespoke for the declarative fields. Unset → the engine uses the declarative path.
position INT no Ascending match-priority hint (coarse tiebreak; literal-segment specificity still wins within equal-length routes).
edited_by INT FK → users.id, indexed no Edit tracking (AISD-126).
creator_type VARCHAR(64) no String reference to creator_types.name.

Relationship: template (the owning Template).

routes

A list of path patterns, e.g. ["/{category}/{slug}"].

  • {name} segments capture a param; the captured value is available to sources params as {name} (see sources explained).
  • A trailing * segment is a catch-all (the 404 descriptor uses ["/*"]).

layout

An ordered list of template element_type content slots to render into the page body.

header and footer are not listed in layout

The worker's renderLayout wrapper adds header and footer around the body. layout lists only the content elements. So an article's layout is ["article"], not ["header", "article", "footer"]. The sitemap descriptor has an empty body, [], because it emits XML, not a page.

Match precedence

When more than one descriptor's routes could match a URL, the worker picks the most specific:

  1. Literal-segment specificity wins within equal-length routes -- a literal segment beats a {capture}. So category's "/category/{slug}" beats the article's "/{category}/{slug}" for the URL /category/news.
  2. position is only a coarse ascending tiebreak; it does not override literal specificity.
  3. The 404 descriptor (["/*"], position 999) is the fallback the engine uses when nothing else matches.

ttl

The rendered page's cache lifetime in seconds. The seeded values reflect how fresh each page needs to be -- 120s for the homepage, 1800s (30 min) for an article, 86400s (1 day) for static legal pages.

The seeded celebrity-v1 descriptors

0015_page_types_and_css_version.sql seeds the full set of "default"-variant descriptors for the celebrity-v1 template (the WeKnow / "WK" site). They are keyed by templates.slug so the seed is environment-agnostic (no hardcoded ids) and idempotent via the (template_id, type, variant) unique key. Every layout slot name matches a template_elements.name seeded in 0001.

type routes layout ttl sources (summary) seo builder position
homepage ["/"] ["homepage"] 120 lists · {"kind":"homepage","limit":30}main {"from":"site"} homepage 5
article ["/{category}/{slug}"] ["article"] 1800 pages · {"slug":"{slug}","related_limit":6}, required → page {"from":"page"} article 40
category ["/category/{slug}","/{slug}"] ["category"] 300 lists · {"kind":"category","category_slug":"{slug}"}list {"from":"site"} category 50
author ["/author/{slug}"] ["author"] 900 listslist and authorsauthor (two sources) {"from":"site"} author 35
contact_us ["/contact-us","/contact"] ["contact_us"] 86400 none {"from":"site"} static 20
about ["/about","/about-us"] ["about"] 86400 none {"from":"site"} static 20
privacy_policy ["/privacy-policy","/privacy"] ["privacy_policy"] 86400 none {"from":"site"} static 20
terms_of_use ["/terms-of-use","/terms"] ["terms_of_use"] 86400 none {"from":"site"} static 20
do_not_sell ["/do-not-sell"] ["do_not_sell"] 86400 none {"from":"site"} static 20
search ["/search"] ["homepage"] 60 lists · {"kind":"homepage","limit":30}main (reuses the homepage shape) {"from":"site"} homepage 20
sitemap ["/sitemap.xml"] [] 3600 none {"from":"site"} sitemap 10
404 ["/*"] ["404"] 300 none {"from":"site"} notfound 999

Observations grounded in the seed:

  • The static pages (contact_us, about, privacy_policy, terms_of_use, do_not_sell) carry no sources -- they are pure layout + the static builder, cached a full day.
  • search intentionally reuses the homepage list shape ("refine later" per the seed comment), with a short 60s ttl.
  • sitemap has an empty layout ([]) and the sitemap builder; it emits XML, not a wrapped page.
  • The 404 is the engine fallback, matched by the ["/*"] catch-all with the highest position.

The next page walks the three descriptors that actually fetch data -- homepage, article, and author -- line by line.