Sections and pages in `template.ts`
The `template.ts` declaration — composing section instances, laying them out across the site's pages, and the cross-section `reconcile` rules a template owns.
The template’s template.ts declares the section instances the site is built from
and lays them out across its pages. composeSections builds the instances — a flat
pool with no page in it — and page() / composePages() declare the site map.
defineTemplate({ … }) declares the artifact class with output and takes the
pages; there is no flat shape, and a one-page site is declared exactly as a
five-page one is.
defineTemplate is a discriminated union on output — "website", "image", or
"document". Every branch declares formats, its acceptance set of format
values: a website declares exactly [presets.website], the one geometry-less
format, while an image or document template declares one or more of its own
class’s formats — preset library entries (presets.instagramSquare,
presets.letterPortrait, …) or custom values. Each branch also
accepts only its own page form — page(label, path, sections) for a website,
pathless page(label, sections) for the rendered classes — so mixing classes
fails to compile rather than to validate. A design spanning classes is two
templates sharing sections.
name is the template’s display name, and it is at most two words — a longer one
is refused at defineTemplate, not at publish. The platform shows it on one
truncating line in the picker card, the editor chrome and the dashboard alike, so say
what the template is ("Classic Carousel") and leave the description to the
preview blurb.
composePages’s object key is the page id and object order is page order; within a
page, array order is render order. It returns the pages back as handles keyed by
those same ids, so a later reference to a page is typed — renaming a page breaks
the build rather than anything downstream. Every page declares a
non-empty label — it is what a nav menu renders — and exactly one page takes the
path "/". Other paths are one lowercase-kebab segment ("/gallery").
composeSections’s object key is the section’s folder name under sections/,
verbatim and kebab ("price-range", not a camelCased spelling of it), since every
stage resolves a composed entry by matching it against the section folders on disk. Composing one section twice is the
one case where the key is free: the entry then carries the folder name as section:.
An entry naming no folder is refused wherever it is read — check reports an unknown
section, and the dev server refuses the page naming the key, rather than rendering it
with the section quietly missing. The reverse is not an error: a section folder
nothing composes is ordinary work in progress.
Listing the same handle on two pages is one shared instance — one content, one identity — rendered on each of them. Two handles are two independent instances.
Because the whole composition is typed, a cross-section
rule names a slot by its typed handle (sections.hero.slots.headline) rather than a
string nothing checks. See template-invalid for the
full contract this file is validated against.
import { composePages, composeSections, defineTemplate, page, presets } from "@homepages/template-kit";
export const sections = composeSections({ header: { schema: headerSchema, instanceId: "header-1", required: true }, hero: { schema: heroSchema, instanceId: "hero-1" }, "image-slider": { schema: sliderSchema, instanceId: "image-slider-1" },});
// `header` is listed on both pages: one instance, rendered on each.export const pages = composePages({ home: page("Home", "/", [sections.header, sections.hero]), gallery: page("Gallery", "/gallery", [sections.header, sections["image-slider"]]),});
export default defineTemplate({ key: "acme-modern", name: "Acme Modern", output: "website", formats: [presets.website], scope: "global", pages,});A page the end user may switch off takes { optional: true } as page()’s fourth
argument — page("Contact", "/contact", [sections.contact], { optional: true }). The
"/" page can never be optional: a site must serve its root.
Canvases — grouping pages into editing surfaces
Section titled “Canvases — grouping pages into editing surfaces”Pages are artifact structure; canvases are editing-surface structure — what
appears together on one canvas of the platform’s editor. Most templates never
declare them: leaving canvases off defineTemplate takes the class default —
a website edits as one canvas per page, labeled by the page’s label, and an
image or document template edits as a single canvas holding every page, labeled
by the template’s name.
Declare canvases to group pages differently — say, a document whose overview
pages edit side by side while the gallery stands alone:
import { canvas, composeCanvases, composePages, composeSections, defineTemplate, page, presets } from "@homepages/template-kit";
export const sections = composeSections({ hero: { schema: heroSchema, instanceId: "hero-1", required: true },});
export const pages = composePages({ cover: page("Cover", [sections.hero]), stats: page("Stats", [sections.hero]), photos: page("Photos", [sections.hero]),});
export default defineTemplate({ key: "acme-brochure", name: "Acme Brochure", output: "document", formats: [presets.letterPortrait], scope: "global", pages, canvases: composeCanvases({ overview: canvas("Overview", [pages.cover, pages.stats]), gallery: canvas("Gallery", [pages.photos]), }),});composeCanvases mirrors composePages: the object key is the canvas id and
object order is canvas order — the order the editor’s canvas bar presents them
in. A canvas(label, pages) takes the handles composePages returned, so a
renamed page breaks the build, never the editor; its page list order is the
display order on that canvas. Canvas grouping never changes artifact order —
the published site map, sheet order, or image-set order stays composePages
order regardless.
Explicit canvases must partition the pages: every page on exactly one canvas, no canvas empty, every canvas labelled. Anything else fails validation.
Linking across pages
Section titled “Linking across pages”A renderer reads nav.anchors[targetKey] exactly as it always has; what it resolves
to depends on where the target sits. A section on the page being rendered gives a bare
#anchor; a section on another page gives that page’s URL plus the fragment
(/gallery/#photos). Nothing in the renderer branches on where the target is.
It does branch on whether the target is reachable. anchors obeys the end user’s
visibility switches at both levels: a target carried only by pages they have switched
off — or a section they have switched off everywhere it is composed — is absent
from the map rather than resolving to something that publishes nothing.
So gate every cross-section control on the resolved href — a bare read or a !
assertion ships an <a> whose href React drops, which is a dead, unclickable
control where there should be none:
import type { SectionNav } from "@homepages/template-kit";
function InquireLink({ nav }: { nav: SectionNav }) { const contactHref = nav.anchors.contact; return contactHref === undefined ? null : <a href={contactHref}>Inquire</a>;}The page being rendered always counts as reachable for its own render, so a same-page
#anchor keeps working on an optional page’s own preview. A switched-off section gets
no such exception — it renders nowhere, the current page included.
To build a menu, read nav.pages — the site’s pages in composition order, each with
its id, label, href and a current flag, with any switched-off page already
excluded.
Cross-section rules with reconcile
Section titled “Cross-section rules with reconcile”See Composition and reconcile for why this belongs to the template rather than to either section.
defineReconcile(sections) binds two helpers to the composition you just
declared, and defineTemplate’s reconcile array carries the rules you build with
them:
import { composePages, composeSections, defineReconcile, defineTemplate, page, presets } from "@homepages/template-kit";
export const sections = composeSections({ hero: { schema: heroSchema, instanceId: "hero-1", required: true }, "image-slider": { schema: sliderSchema, instanceId: "image-slider-1" },});
const { rule, set } = defineReconcile(sections);
export default defineTemplate({ key: "acme-modern", name: "Acme Modern", output: "website", formats: [presets.website], scope: "global", pages: composePages({ home: page("Home", "/", [sections.hero, sections["image-slider"]]), }), reconcile: [ rule( "slider drops the photo the hero already used", { reads: [sections.hero.slots.hero_image, sections["image-slider"].slots.images] }, ({ site }) => { const heroImage = site.hero.hero_image as { asset_id: string } | null; const images = site["image-slider"].images as { asset_id: string }[]; const kept = images.filter((image) => image.asset_id !== heroImage?.asset_id); // A threshold is ordinary code — there is no rule vocabulary to look up. if (kept.length < 3) return []; return [set(sections["image-slider"].slots.images, kept)]; }, ), rule( "a listing with almost no photos empties the slider", { reads: [] }, ({ facts }) => (facts.photos.length < 4 ? [set(sections["image-slider"].slots.images, [])] : []), ), ],});A rule is a pure function, not a declaration. rule(description, { reads }, fn)
takes a short description (it names the rule if it ever fails), a reads
declaration, and your function. reads lists every slot the function reads off
site, as typed handles (sections.hero.slots.hero_image) — it is how the editor
knows a user’s edit to one of those slots must re-run the rule. It is required, and
an empty reads: [] is legal and meaningful: it says the rule reads only facts,
so no slot edit should trigger it, as in the second example above. The function
receives one argument with two members:
site— one FLAT namespace spanning every page: one entry per composed section instance, keyed by its compose key, and inside it the section’s own slot keys (site.hero.hero_image). A shared instance appears once however many pages render it. Both key levels are typed from the real composition, so renaming a slot or a compose key fails to compile instead of leaving a rule that silently never fires. Slot values arrive asunknown— narrow them yourself, as above.facts— the same derive-timeFactsview acompute()callback receives. One fact surface for both, so there is nothing extra to learn here.
One verb, and you return it as data.
| Verb | Takes | Means |
|---|---|---|
set(slotHandle, value) |
a slot handle off sections (sections["image-slider"].slots.images) |
replace that slot’s value on that instance |
The handle comes from composeSections, so each edit already names the instance it
targets. Your function returns an array of edits and the platform performs them —
a rule cannot reach into the site and rewrite it. A set is an edit the platform
may decline: when the end user has already edited that slot, their value stands, and
the rule is given no return value, no error and no way to opt out — see
Make two sections agree. Return [] to do
nothing. The Edit and SetEdit types are
exported if you factor a rule body out into a helper of your own. Whether a section
shows at all is not a reconcile concern — that is the schema’s own display: block
(The schema system).
Rules run in array order, and each one sees the edits the rules before it
produced. A rule must be synchronous and pure — no fetch, no clock, no
randomness, no Node built-in — for the same reason a compute() callback must be: it
runs in a dependency-free bundle, not in a Node process. An async function fails to
typecheck rather than silently running async.
Reconcile addressing is section-keyed: set resolves its slot handle to a
section, so a section you compose twice resolves to the last instance of it. Address
distinct sections when a rule needs to tell two instances apart.
Reconcile is a publish-time behavior. template-kit dev renders the full template
before any rule runs — see Preview with dev — so verify a rule by reading it, not by
looking for its effect in local preview.
See also
Section titled “See also”- Frames and formats — how a page written once serves every format an image or document template declares.
- Composition and reconcile — why cross-section behaviour belongs to the template, and why reconcile runs at publish.
template-invalid— the full contract this file is validated against.- Add another template — the workspace layout a second template needs.
- Preview with
dev— why a reconcile rule’s effect is not visible in local preview.