Make two sections agree
Add a cross-section rule to a template, so two composed sections stop contradicting each other.
Stop two sections on the same page contradicting each other — a gallery showing the photograph the hero already used, say. Neither section can see the other, so the rule belongs to the template that composes both.
Prerequisites
Section titled “Prerequisites”- A template composing two sections — Add a second template adds a second section and says where to compose it.
- An image slot on each of them — Crop an image slot adds one, and its edit applies to both sections unchanged.
composeSections returns one typed handle per instance, which is why template.ts
exports it. defineReconcile(sections) binds two helpers to that composition:
rule, plus the one verb a rule returns. Add defineReconcile to template.ts’s
existing kit import — the scaffolded file takes the composition builders and
defineTemplate, so it is missing until you do — then destructure what the rule uses:
// template.ts — below `composeSections`, above `defineTemplate`const { rule, set } = defineReconcile(sections);Then one entry in defineTemplate’s reconcile array. The scaffolded file already
declares it empty, so this fills it in:
// template.ts — one entry in `defineTemplate`'s `reconcile` arrayrule( "the gallery never repeats the photograph the hero already used", { reads: [sections.main.slots.interior_image, sections.gallery.slots.interior_image] }, ({ site }) => { const hero = site.main.interior_image as { photo_id: string } | null; const gallery = site.gallery.interior_image as { photo_id: string } | null; if (!hero || !gallery) return []; return hero.photo_id === gallery.photo_id ? [set(sections.gallery.slots.interior_image, null)] : []; },),A rule is a function, not a declaration. There is no rule vocabulary to look up and no predicate language to learn: a threshold is an ordinary conditional and a comparison is ordinary code, so anything you can write over the values is legal. The description is the first argument because it names the rule if it ever fails.
reads is a trigger declaration, not a permission. It lists every slot the
function reads off site, as typed handles, and the editor matches a user’s edit
against that set to decide whether to re-run the rule — so a slot you read but leave
out is not an access error, it is a rule that silently never re-runs for that edit.
It is required. An empty reads: [] is legal and says something real: the rule reads
only facts, so no slot edit should trigger it.
site is keyed by compose key, then by the section’s own slot keys. The two keys
above are your own composeSections object’s — the second one is whatever you named
the section you added. Both levels are typed from the real composition, so renaming
either fails to compile rather than leaving a rule that quietly never fires. Slot values arrive as unknown — narrow
them yourself, as above. Beside site the function receives facts, the same
derive-time view a compute() callback gets.
You return edits as data; the platform performs them. set(slotHandle, value)
replaces one slot’s value on the instance that holds it — unless the end user has
already edited that slot, in which case the platform declines the set and keeps
their value. The decline is silent and has no opt-out: your rule gets no return
value, no error, and cannot ask for one, because overwriting a value the user chose
deliberately is the defect rather than the repair. So a rule that looks like it
never ran on a real deliverable has often had its set declined — the rule itself
ran and returned correctly, and no amount of logging inside it will show otherwise.
The verb takes handles off sections, so every edit already names its own instance.
Return [] to do nothing. Rules run in array order and each one sees the edits the
rules before it produced. (Whether a section shows at all is not a reconcile
concern — that is the schema’s own display: block; see
Add a variant.)
A rule must be pure and synchronous — no clock, no randomness, no network, no
Node built-in — because it runs in a dependency-free bundle rather than in a Node
process. An async function fails to typecheck rather than silently running async.
Addressing is section-keyed: a section composed twice resolves to its last instance, so name distinct sections when a rule has to tell two instances apart.
Complete diff
Section titled “Complete diff”Two additions, both to template.ts, plus defineReconcile on its existing kit
import:
// template.ts — below `composeSections`, above `defineTemplate`const { rule, set } = defineReconcile(sections);// template.ts — in `defineTemplate`'s `reconcile` arrayrule( "the gallery never repeats the photograph the hero already used", { reads: [sections.main.slots.interior_image, sections.gallery.slots.interior_image] }, ({ site }) => { const hero = site.main.interior_image as { photo_id: string } | null; const gallery = site.gallery.interior_image as { photo_id: string } | null; if (!hero || !gallery) return []; return hero.photo_id === gallery.photo_id ? [set(sections.gallery.slots.interior_image, null)] : []; },),Verify
Section titled “Verify”Verify with the author loop, with one thing to expect:
reconcile is a publish-time behaviour, and template-kit dev renders the full
template before any rule runs — so the loop proves the template loads and every
reads handle resolves, not that the rule fired. Read the rule to know what it does.
Rules that can fire
Section titled “Rules that can fire”template-invalid—template.tsfails to build or to load, which includes areadsentry naming a section the template does not compose.no-nondeterminism— the rule body reads the clock or a random source instead of the values it is handed.no-client-runtime-in-server— the rule body reaches for the network or another browser-only API.
See also
Section titled “See also”template.ts— the full contract:rule’s three arguments and whatsettakes.- Composition and reconcile — why cross-section behaviour belongs to the template, and why it runs at publish.
- Preview with
dev— why a rule’s effect is not visible in local preview.