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 tosourcesparamsas{name}(see sources explained).- A trailing
*segment is a catch-all (the404descriptor 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:
- Literal-segment specificity wins within equal-length routes -- a literal
segment beats a
{capture}. Socategory's"/category/{slug}"beats the article's"/{category}/{slug}"for the URL/category/news. positionis only a coarse ascending tiebreak; it does not override literal specificity.- The
404descriptor (["/*"],position999) 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 | lists → list and authors → author (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 nosources-- they are pure layout + thestaticbuilder, cached a full day. searchintentionally reuses the homepage list shape ("refine later" per the seed comment), with a short 60sttl.sitemaphas an emptylayout([]) and thesitemapbuilder; it emits XML, not a wrapped page.- The
404is the engine fallback, matched by the["/*"]catch-all with the highestposition.
The next page walks the three descriptors that actually fetch data --
homepage, article, and author -- line by line.