Skip to content
HomePagesHomePages template kit
Menu

The schema system

The section authoring contract — schema.ts, fill-spec.ts, fixtures.ts, and the closed source/transform vocabulary.

Everything here is imported from the package root:

import { direct, type SectionProps, type SectionSchemaShape } from "@homepages/template-kit";

One specifier for everything a section renders from, on purpose. The lone exception is @homepages/template-kit/fixtures, which fixtures.ts imports the golden scenarios from — dev-only sample data is kept off the root barrel so it can never reach a published renderer’s import graph.

A section is four files in one folder. Three of them are declarations the platform reads; the fourth is your React.

File Declares Key type
schema.ts The slots and options the section has SectionSchemaShape
fill-spec.ts How AI fills those slots FillSpecShape
fixtures.ts Sample content for local preview FixtureModule
Renderer.tsx The markup SectionProps<typeof schema>

Declare the schema as const satisfies SectionSchemaShape. The as const is load-bearing: it preserves the literal types that everything downstream infers from. Without it, SectionProps cannot tell a required slot from an optional one and your renderer props collapse to a useless widened shape.

import { direct, type SectionProps, type SectionSchemaShape } from "@homepages/template-kit";
export const schema = {
meta: { displayName: "Hero" },
slots: {
hero_image: { type: "image", required: true, produced_by: "hero_image_assign", crop: { mode: "locked", aspect: 16 / 9 } },
headline: { type: "text", size: "medium", required: true, source: direct("address") },
blurb: { type: "text", size: "long", required: true, produced_by: "blurb_text" },
},
options: {
align: { type: "enum", values: ["left", "center"], default: "center" },
},
} as const satisfies SectionSchemaShape;
export type Props = SectionProps<typeof schema>;

A slot is content: something a user can edit or AI can fill. An option is behavior: a switch the section reads to render differently. If a user would type into it, it is a slot.

direct(field) binds a slot to a known fact about the property; derived(name) binds it to a computed transform. Both take names from a closed vocabulary, so a typo is a compile error at the call site — not a runtime surprise:

source: direct("address") // ✅
source: direct("addresss") // ✗ compile error: not a SOURCE_FIELDS key

The vocabulary is the kit’s, not yours. Adding to it is a kit release, not a change you can make in a template. See the closed vocabulary for the sets and when to pick each; your editor autocompletes the exact fields.

A select slot binds the same way as any other type — here fact-bound rather than AI-produced, with values declaring the closed set the fact resolves to:

listing_status: { type: "select", required: false, source: direct("listing_intent"), values: ["for_sale", "for_rent"] },

A video slot (or a video_collection) declares a hosted video the platform serves — not a link to a third-party player. It takes the same frame as an image, plus a playback config, and it takes no crop: a video is selected, never edited, so there is no cropper to steer and no re-edit state on the value.

hero_loop: {
type: "video",
frame: { breakpoint: 768, desktop: { aspect: 16 / 9 } },
playback: { autoplay: true, muted: true, loop: true, controls: false, preload: "auto" },
},

Playback is author-declared per slot because the platform cannot infer it from the asset — a silent hero loop and a narrated walkthrough need opposite behavior. The fields are autoplay, loop, muted, controls, playsinline, and preload ("none" | "metadata" | "auto"); preload: "none" is the bandwidth lever for a below-fold video most visitors never reach. Omit playback entirely and Video renders a plain controls player.

autoplay requires muted. Every browser blocks unmuted autoplay, so a slot declaring one without the other fails check rather than shipping a video that silently never plays. The type system cannot express the pair, so the check failure is the guard.

A VideoValue is { video_id, url, poster, alt, width?, height? }. url is always a single progressive MP4 rendition — there is no responsive ladder and no mobile variant to pass along, unlike an image. Read url, poster, alt, and the intrinsic width/height, and hand them to Video.

AI fills a video slot with a video-assign decision and a video_collection with video-collection — the video twins of image-assign / image-collection, pooling the property’s videos rather than its photos. They are separate decision types rather than a filter on the image ones so a decision’s candidate pool is legible from its type alone. Both take an optional tag_query (omit it to pool across every video) and a selection_prompt; selection reads only text — tags, caption, duration — never the video itself. Two fields the image side has are absent by design:

  • No from_fact. Nothing in the closed vocabulary names a video upload the way headshot names an image one, so a video reaches a slot only by selection.
  • No mode. The photo side’s walkthrough sequences every photo into a room-by-room tour, which presumes a pool large enough to order; a property carries a handful of videos.

meta.groups collapses several slots into a single card in the editor sidebar. Each group declares an id, a label, and its members — a bare slot id is a full-width row, a [a, b] tuple is one 2-up row:

meta: {
displayName: "Contact",
groups: [
{ id: "agent", label: "Agent", members: ["agent_name", ["agent_email", "agent_phone"]] },
],
},

The renderer must match this by wrapping the members in <SlotGroup id="agent"> — see Contract primitives.

Renderer.tsx — props derived, never hand-written

Section titled “Renderer.tsx — props derived, never hand-written”

Never hand-write your prop type. Derive it, and the schema stays the single source of truth:

import { Section, Slot } from "@homepages/template-kit";
import type { Props } from "./schema.js";
export default function Hero({ slots, options, nav }: Props) {
// headline is `string` — the schema says required: true
// options.align is `"left" | "center"` — the enum's declared values
return (
<Section id={nav.selfAnchor}>
<Slot id="headline" as="h1" textLeaf>
<span>{slots.headline}</span>
</Slot>
</Section>
);
}

SectionProps reads the schema literal and resolves each slot to its runtime value shape: a required text becomes string, an optional one string | null, an image becomes ImageValue, an image_collection an array, and so on. A slot you did not declare is a compile error to read.

An ImageValue splits into fields a renderer reads and fields it only carries. Read url, alt, responsive, and mobile, and pass them to Image. Ignore rect, ar, and crop_key: they are the editor’s re-edit state (so its cropper can reopen at the last crop) and the provenance of the already-cropped url. Framing is baked into the asset the platform serves, never applied at render time — an uncropped image is a centered object-cover over the original and a cropped one is a pre-cropped derivative, so there is no framing for a renderer to apply. A slot’s crop config steers the editor’s cropper, not your markup.

The markup a section is required to emit — the section root, the editor’s slot markers, images, videos — comes from the kit’s six primitives; everything else is your own JSX. See Contract primitives.

Renderers must be pure functions of props — no fetches, no globals, no useEffect for data, no Date.now() / Math.random(). They are rendered to static markup by the publisher and re-mounted constantly by the editor.

Each decision produces one or more slot names. A slot filled by AI names its decision in produced_by; a slot bound with direct() needs no decision, because the fact is baked in at fill time.

import type { FillSpecShape } from "@homepages/template-kit";
export const fillSpec = {
reads: [],
decisions: [
{ id: "hero_image_assign", type: "image-assign", produces: ["hero_image"], tag_query: "category:exterior" },
{
id: "blurb_text",
type: "text-block",
produces: ["blurb"],
length_cap: 400,
structure: "paragraph",
voice_prompt: "Warm, concrete paragraph a buyer would want to read.",
},
],
} as const satisfies FillSpecShape;

base is one fully-specified baseline: every required slot with a real value — and every optional slot the scenario can plausibly fill, too. Coverage runs one way: the checks synthesize an empty fixture for each optional slot your base populates, but nothing populates a slot your base leaves empty. A slot empty in base has its filled branch rendered by no fixture at all, so a section can break that branch with every check green.

states adds named states, each overriding part of base — use them for the structural cases your section is designed to handle (a missing logo, a long headline, an empty list).

import type { FixtureModule } from "@homepages/template-kit";
import { scenarios } from "@homepages/template-kit/fixtures";
const fixtures: FixtureModule = {
base: {
slots: {
headline: "101 Queensway, Jamaica Plain, MA 02130",
// An image slot states the asset id a real page holds — never a URL. The
// preview server resolves it to a url, an alt fallback, and a responsive
// ladder, exactly as the platform does when the page is published.
hero_image: { photo_id: scenarios.luxuryMultiUnit.photos[19]!.id, alt: "Front elevation" },
blurb: "A bright corner home moments from the Emerald Necklace.",
},
options: { align: "center" },
},
states: {
left: { label: "Left-aligned", options: { align: "left" } },
},
};
export default fixtures;

Preview is only as honest as the data behind it, so the kit ships three complete, property-shaped scenarios to build fixtures from — real addresses, unit counts, and contacts, the cases that actually break a layout. Pick the one whose breadth matches the state you’re exercising:

import { scenarios, primaryContact, poisByCategory, propertyMetrics, contactCards }
from "@homepages/template-kit/fixtures";
const s = scenarios.luxuryMultiUnit; // or sparseSingleFamily / forRentCondo
Scenario Breadth
luxuryMultiUnit 12-unit multi-family, for-sale, 3-agent team, 24 POIs, 20 photos, both logos, video + 3D tour — the breadth ceiling every section’s base fixture builds on
sparseSingleFamily 1-unit single-family, for-sale, 1 contact, 4 POIs, 5 photos, no logos, no video/tour — the sparse floor a states entry exercises
forRentCondo 1-unit condo/townhome, for-rent, 2 contacts, 8 POIs, 10 photos, both logos, video only — the mid case

scenarios is also a Record<ScenarioKey, Scenario> for a picker; import a named scenario (luxuryMultiUnit, sparseSingleFamily, forRentCondo) directly when you only need one. This is a separate entry, deliberately not on the root barrel — dev-only sample data must never be reachable from a published renderer bundle’s import graph.

Fill every slot from the scenario, by its fill-spec source

Section titled “Fill every slot from the scenario, by its fill-spec source”

A fixture’s job is to answer, for every slot, “what would this look like on a real property?” — never to invent an answer. The scenario already has one; the fill-by-source rule says where to find it, keyed to the same source / produced_by a slot declares in schema.ts:

Slot’s fill-spec source How the fixture fills it
direct (a fact) read it off the scenario — s.facts.address.display, primaryContact(s).fields.phone
derived a helper — propertyMetrics(s), poisByCategory(s), contactCards(s)
ai a short sample fill grounded in the scenario’s facts — a headline about this property, never lorem ipsum or invented facts

A fixture that gets this wrong is worse than a missing one: a slot bound direct("beds") whose fixture value doesn’t match s.facts.units[0].beds teaches nothing about the layout it’s supposed to preview.

The helper functions project a scenario’s raw facts into the shape a fixture usually wants, without re-typing the scenario’s data:

Helper Returns
primaryContact(s) The scenario’s primary contact (ContactFixture, position 0 by construction) — throws if the scenario has none
contactCards(s) Every attached contact, in order (ContactFixture[])
poisByCategory(s) The scenario’s POIs bucketed by category (Record<PoiFixture["category"], PoiFixture[]>)
propertyMetrics(s) Beds/baths/sqft/price off the lowest-position unit, *Min/*Max/priceStart across all units, and addressLine1/addressLine2

propertyMetrics returns kit fact vocabulary — addressLine1/addressLine2, never a template’s own slot names, so the kit never learns what a section calls its slots. Map it to your slots at the call site: address_street: m.addressLine1.

s.photos, s.pois, s.amenities, s.contacts, s.media, and s.facts (the raw PropertyFacts) are also readable straight off the scenario for slots that don’t need a helper’s projection — a gallery slot can bind directly to s.photos. A contact’s org name and logos (logo_light_id/logo_dark_id) live on ContactFixture["fields"], not on a sibling “brand” object — read them off primaryContact(s).fields or s.contacts.

A fixture’s job is to state what a saved page holds, and a saved page holds an asset id — the platform turns that id into a served URL at render time. So a fixture does the same: give an image slot a photo_id (a video slot a video_id), and the url, the alt fallback, and the responsive ladder are attached for you when the fixture loads, in the preview server and in check alike. A fixture that states a URL is exercising a path real content never takes.

The ids to use are the scenario’s own: s.photos[n].id for a photo, primaryContact(s).fields.headshot_id / .logo_light_id / .logo_dark_id for a contact’s images, s.media.video_id for the hosted video. s.media.tour_3d_url stays a URL on purpose — a 3D tour is a third-party embed the platform does not host, so there is no asset to name. Floor plan refs (s.facts.units[*].floor_plans) likewise carry only id/floor_label/position; their url, alt, and responsive are optional and appear only after resolution.

An id that names nothing resolves to an empty image, which previews as an empty slot rather than an error — so take the ids off the scenario rather than typing them.

See Pick a scenario for the full picker and helper cheat-sheet.

A scenario is the fixture view of a property, not the fact bundle the AI-fill pipeline reads. The difference matters when you write a fill-spec.ts:

  • Scenarios carry values that only exist after fill — UnitRow.description and features are AI-written copy. Nothing upstream produces these; they are here so your renderer has something real to draw.
  • Scenarios hoist some facts out of facts and onto the scenario itself, in the shapes a renderer wants: photoss.photos (PhotoFixture[]), poiss.pois (PoiFixture[]), and the fact bundle’s hosted videos and tourss.media — a single MediaFixture object, not an array: video_id names one hosted video asset, tour_3d_url the third-party embed link. Floor plans hoist too, but onto each unit: s.facts.units[*].floor_plans (FloorplanAssignment[]).
  • contacts is duplicated, not hoisted. s.contacts holds the render-shaped ContactFixture[] your renderer draws, and facts.contacts still exists as well — so a fill-spec that reads or iterates facts.contacts has a real counterpart in every scenario.
  • facts.address is the exception: it mirrors the real address contract field for field (display, street, street_no_type, city, state, postal, and an optional unit). A fill-spec that seeds from ${facts.address.city} has a real counterpart in every scenario. Note street_no_type drops the street-type suffix — “845 Pearl” for “845 Pearl Street” — so never seed one from the other.

Address lines are derived, never stored: use propertyMetrics(s).addressLine1 / addressLine2, which compose the sub-parts exactly as the publishing pipeline does. Don’t hand-assemble or split them — a fixture must state the value the real pipeline bakes.

Variants — rendering differently based on the facts

Section titled “Variants — rendering differently based on the facts”

A variant axis picks a case from the property’s facts, before any content is filled. Declare the axis in meta.variants with its cases, a default for when facts are missing, and a pure select function. Your renderer receives the resolved case on props.variants.

meta: {
displayName: "Floorplan",
variants: {
layout: {
cases: ["single", "multi"],
default: "single",
select: (input) => (input.units.length > 1 ? "multi" : "single"),
},
},
},

select receives a TransformInput and must be pure — it runs inside the published bundle. A case it returns that is not in cases falls back to default.

manifest.json — composing sections into a template

Section titled “manifest.json — composing sections into a template”

The template’s manifest.json lists the ordered section instances that make up a page. It is validated against the exported TemplateManifest schema.