Template Data
TemplateData is the union of everything a resolved request can hand a template. Nine members, each tagged with a kind, each with a guard that narrows to it.
Overview
Section titled “Overview”Which member arrives depends on which rule matched. Bind a template through a builder that already knows the shape, such as entryType() or forEntryType("recipe"), and the type parameter is settled for you. data is EntryTypeArchiveData and nothing else. Bind it through fallback() and data is the whole union, because a catch-all can receive any of the ten.
Narrow the union in one of two ways. A switch on data.kind narrows in every branch.
import { defineTemplate } from "plumix/theme";
export const anything = defineTemplate({ render: ({ data }) => { switch (data.kind) { case "entry": return <h1>{data.entry.title}</h1>; case "search": return <h1>{data.query}</h1>; default: return <h1>{data.kind}</h1>; } },});Dropping the default does not make the compiler demand the other eight. render returns ReactNode, ReactNode includes undefined, and the shared tsconfig sets no noImplicitReturns, so a switch that falls off its last case compiles and renders nothing. To turn a missing case into a build error, replace the default return with const exhaustive: never = data;, which stops compiling the day an eleventh kind lands.
For a single branch, the guards below read better. Each is a plain function exported from plumix/theme that takes TemplateData and narrows.
Three projections recur across the shapes.
ResolvedEntryis the stored entry row withmetareplaced by its decoded counterpart, with every reference field hydrated, plusstoredMeta,images,terms,author,contentBlocksandurl.storedMetais the meta JSON column as the row holds it, kept beside the decoded bag because that is what a rule predicate compares against — see.whereMeta().contentBlocksis the content narrowed to the block-tree shape, andnullwhen the stored JSON does not fit that shape.urlisnullfor a hierarchical entry still awaiting an ancestor-chain walk.ResolvedTermis the term row plus its ownurl,storedMetaandimages, and itsmetagets the same treatment as an entry’s — decoded, with every reference field hydrated.storedMetais the meta JSON column as the row holds it, so the two differ on a term exactly as they do on an entry: a.returns("date")field reads aDateoffmetaand the stored ISO string offstoredMeta, and a reference reads the row it points at off one and the stored id off the other. A rule predicate compares againststoredMeta— see.whereMeta().ResolvedAuthorisid,slug,name,avatarUrlandimages. There is no email and no auth column, and the meta bag the images came from does not travel with them. On the entry path the query selects those columns and nothing else; on an author archive the resolver loads the whole user row to find the author, then copies the same fields into a fresh object rather than spreading the row.
All three carry images: the entity’s picture for each image role its scope declares a field in, keyed by role name and typed from ImageRoles, so a template asks for data.entry.images.featured?.url rather than guessing a meta key. A role reads null when no field in it resolved to an image — an orphaned reference, or one the media adapter refuses — and is absent when the scope declares no field in it at all. The images are projected out of the meta the page already hydrated, so reading them costs no query.
Six of the ten shapes carry entries and a Pagination of page, perPage, total and pageCount. Every listing shape is generic over the entry projection and defaults to ResolvedEntry, so a targeted rule can hand you a narrower entry type without a cast.
The shapes
Section titled “The shapes”EntryData
Section titled “EntryData”One entry. kind: "entry", guard isEntry, bound directly by the entry tier and by forEntryType(name).template().
import { BlockRenderer } from "plumix/blocks/renderer";import { defineTemplate, isEntry } from "plumix/theme";
export const single = defineTemplate({ render: ({ data }) => isEntry(data) ? ( <article> <h1>{data.entry.title}</h1> <BlockRenderer content={data.entry.contentBlocks} /> </article> ) : null,});data.entry is the only field. It is a full ResolvedEntry, so the title, excerpt, status, publish date, author, terms and decoded meta are all on it. It is one of the four listing-free shapes, alongside ArchiveTypeData, ViewData and ErrorData. There is no entries and no pagination on any of them.
EntryTypeArchiveData
Section titled “EntryTypeArchiveData”An entry type’s listing. kind: "entryType", guard isEntryType, bound by the entryType tier and by forEntryType(name).archive.template().
import { defineTemplate, isEntryType } from "plumix/theme";
export const listing = defineTemplate({ render: ({ data }) => isEntryType(data) ? ( <section> <h1>{data.contentType}</h1> <ol> {data.entries.map((item) => ( <li key={item.id}>{item.title}</li> ))} </ol> <p> Page {data.pagination.page} of {data.pagination.pageCount} </p> </section> ) : null,});data.contentType is the entry type being listed, so one template can serve every archive on the site. A page number past the last page 404s before the template runs, which spares the template an empty-listing branch for that case.
TermArchiveData
Section titled “TermArchiveData”A term’s archive. kind: "term", guard isTerm, bound by the term tier and by forTermTaxonomy(name).
import { defineTemplate, isTerm } from "plumix/theme";
export const terms = defineTemplate({ render: ({ data }) => isTerm(data) ? ( <section data-taxonomy={data.taxonomy}> <h1>{data.term.name}</h1> {data.term.description ? <p>{data.term.description}</p> : null} <p>{data.pagination.total} entries</p> </section> ) : null,});data.term is the subject and data.taxonomy names which taxonomy it belongs to, so you can tell cuisine from diet without reading the term. entries stays the base entry shape here, because one taxonomy can classify several entry types.
AuthorArchiveData
Section titled “AuthorArchiveData”An author’s entries. kind: "author", guard isAuthor, bound by the author tier and by forAuthor().
import { defineTemplate, isAuthor } from "plumix/theme";
export const byAuthor = defineTemplate({ render: ({ data }) => isAuthor(data) ? ( <section> <h1>{data.author.name ?? data.author.slug}</h1> {data.author.avatarUrl ? ( <img src={data.author.avatarUrl} alt="" /> ) : null} <p>{data.pagination.total} published</p> </section> ) : null,});data.author is a ResolvedAuthor, and name is nullable, so fall back to the slug rather than rendering an empty heading.
DateArchiveData
Section titled “DateArchiveData”Entries published in one period. kind: "date", guard isDate, bound by the date tier and by forDate(...).
import { defineTemplate, isDate } from "plumix/theme";
export const period = defineTemplate({ render: ({ data }) => { if (!isDate(data)) return null; const label = [data.year, data.month, data.day] .filter((part) => part !== null) .join("/"); return <h1>{label}</h1>; },});year is always a number. month and day are 1-based and null at a coarser granularity. The three together tell you which archive you are on, and a year archive has both of them null.
ArchiveTypeData
Section titled “ArchiveTypeData”A plugin-registered archive type. kind: "archiveType", guard isArchiveType, bound by forArchiveType(name).
import { defineTemplate, isArchiveType } from "plumix/theme";
export const pluginArchive = defineTemplate({ render: ({ data }) => (isArchiveType(data) ? <h1>{data.name}</h1> : null),});Core only ever sees the base shape: kind, name, and two optional facts an archive states about itself — page, the 1-based pagination index, and query, what the visitor typed on an archive that answers a search. Both are what a consumer such as @plumix/plugin-seo classifies the page by; core cannot derive either from the rest of the payload. The plugin that called registerArchiveType extends it with whatever its archive lists and declares the extended type in ArchiveTypeRegistry, so forArchiveType("seasonal") types data as the plugin’s shape rather than this one. Reached through fallback instead, it stays the base, and the guard is how you find out which archive you are on.
ViewData
Section titled “ViewData”An app page a plugin registered with registerView. kind: "view", guard isView, bound by forView(name).
import { defineTemplate, isView } from "plumix/theme";
export const appPage = defineTemplate({ render: ({ data }) => (isView(data) ? <h1>{data.name}</h1> : null),});Core builds the envelope: name is the registered view, params is what its route captured, and data is whatever the view’s resolve returned. Declare that shape in ViewRegistry and forView(name) types data.data as it; through fallback it is unknown.
FrontPageData
Section titled “FrontPageData”The site root. kind: "frontPage", guard isFrontPage, bound by the frontPage tier.
import { defineTemplate, isFrontPage } from "plumix/theme";
export const home = defineTemplate({ render: ({ data }) => isFrontPage(data) ? ( <main> <h1>Latest</h1> <ol> {data.entries.map((item) => ( <li key={item.id}>{item.title}</li> ))} </ol> </main> ) : null,});The feed is the latest published entries across every public non-hierarchical type. Hierarchical types are standalone content and stay out of it, so a page never appears here alongside a recipe.
SearchData
Section titled “SearchData”Search results. kind: "search", guard isSearch, bound by the search tier.
import { defineTemplate, isSearch } from "plumix/theme";
export const results = defineTemplate({ render: ({ data }) => isSearch(data) ? ( <main> <h1>{data.query}</h1> <p>{data.pagination.total} matches</p> </main> ) : null,});data.query is the decoded query string, ready to render. Everything else on the shape is the same listing pair as an archive.
ErrorData
Section titled “ErrorData”A 404 or a 500. kind: "error", guard isError, bound by the notFound and serverError tiers.
import { defineTemplate, isError } from "plumix/theme";
export const failed = defineTemplate({ render: ({ data }) => { if (!isError(data)) return null; return ( <main> <h1>Something went wrong</h1> <p>{new URL(data.request.url).pathname}</p> {data.errorId ? <code>{data.errorId}</code> : null} </main> ); },});There is no Error field on this shape, by design, so an internal exception message has no path into the rendered page. request is the failing request. hint is an optional short string. errorId is the request’s telemetry id, and only the 500 path sets it. That is the field to test when one template serves both cases.
Related
Section titled “Related”Templates is where each builder names the shape it accepts, so binding through the right one removes the need for a guard. Template Hierarchy is how the resolver decides which rule, and therefore which shape, a request produces. Overview covers the theme descriptor and the plumix/blocks/renderer primitives the examples above use.
Content Modelling models the entry and term rows behind ResolvedEntry and ResolvedTerm, and the decoded meta bag on each comes from the meta boxes in Fields. Blocks describes the tree contentBlocks carries.
Next steps
Section titled “Next steps”Go back to Templates and bind each shape to the tier or matcher that produces it, then to Template Hierarchy to order those rules.
Two unwritten pages in this section carry what a template does with the data once it has it. Component Primitives covers Link and Image, which turn a ResolvedEntry into markup that respects the base path and the site’s image handling, along with useAuth for a page that changes with the signed-in visitor. Tokens and Breakpoints covers the design values those components render against.