Skip to content
HomePagesHomePages template kit

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.

  • 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` array
rule(
"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.

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` array
rule(
"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 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.

  • template-invalidtemplate.ts fails to build or to load, which includes a reads entry 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.
  • template.ts — the full contract: rule’s three arguments and what set takes.
  • 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.