Skip to content
HomePagesHomePages template kit

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.

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.

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 as unknown — narrow them yourself, as above.
  • facts — the same derive-time Facts view a compute() 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.