Section types
A section is a typed block of content owned by one page. Adding one means
three things: an entry in your sections registry, a component, and a line in
your template’s section map.
Sections are not a plugin extension point. They belong to the site, because the component that renders one is part of your design, not part of the engine.
Two halves
Section titled “Two halves”Two halves, and they live in different places on purpose.
| Half | Where | What it decides | Who uses it |
|---|---|---|---|
The entry: schema, form, ai descriptors, defaults |
your sections registry, or a shape from the engine |
what a section is | the admin form, history, and the AI assistant |
| The component | your template | what a section looks like | the visitor |
The engine reads the entry to build the form, diff revisions, and tell the assistant which paths it may write. It never reads your component. Your template reads the value and renders it. It never reads the schema.
Built-in shapes
Section titled “Built-in shapes”Some content has the same shape on every site. Those entries live in the engine as factories, so you compose one line instead of writing a schema, a form and a set of descriptors by hand. You still write the component, because how it looks is yours.
import { faqEntry } from '@ouncepage/core/faq';
export const sections = { faq: faqEntry(),};Eight ship with Ounce. listSection is the general one, for any section that is
a heading plus a repeating list of something; the rest are specific shapes built
on it or beside it. Signatures and examples are in Entry
factories.
Each takes optional title, blurb and defaults, so you can re-label one for
your site without restating its fields:
faq: faqEntry({ title: 'Common questions', blurb: 'Shown at the bottom of the memberships page.',}),faqEntry is worth reading as a worked example of a repeating section. It uses
nothing special: a list field of text and richtext, the shared enabledField,
and cta. A repeating section needs no new field type. list is the
repeater, and the engine already strips items whose enabled is false before
your component sees them.
Two ways to repeat
Section titled “Two ways to repeat”This is the decision to make before you write the component, and getting it wrong is not obvious from the data.
- One section per item. Each entry in the list becomes its own full width band down the page, with its own heading. Good for long-form content.
- One section, a list inside. The whole list renders as a single compact block: an accordion, a grid, a table. Good for questions, features, people, prices.
The stored shape is identical. Only the component differs. If someone asks for “a repeater, not five sections” and your only repeating section renders the first way, the answer is a new section type that renders the second way, not a new field type.
-
Define the entry.
src/site/sections.ts import { z } from 'zod';import { entry, cta, required, text } from '@ouncepage/core';export const sections = {prose: entry({schema: z.object({heading: required,body: text.default(''),}),form: {title: 'Prose',blurb: 'A heading and a block of formatted text.',fields: [{ name: 'heading', label: 'Heading', type: 'text' },{ name: 'body', label: 'Body', type: 'richtext' },],},defaults: {heading: 'Heading',body: '',},}),}; -
Write the component.
src/templates/v1/components/Prose.astro ---import type { SectionProps } from '@ouncepage/core';import type { Sections, Settings } from '../../../site/schema';const { data } = Astro.props as SectionProps<Sections['prose'], Settings>;---<section id="prose" class="mx-auto max-w-2xl px-6 py-16"><h2 class="text-3xl font-semibold">{data.heading}</h2><div class="prose mt-6" set:html={data.body} /></section> -
Register it for rendering.
src/templates/v1/sections.ts import { defineSection } from '@ouncepage/core';import Prose from './components/Prose.astro';import Banner from './components/Banner.astro';export const sections = {prose: defineSection({ component: Prose }),banner: defineSection({ component: Banner, overlayHeader: true }),}; -
Add it to a page with a migration, or let an editor add it in the admin if
composeSectionsis on.migrations/0003_prose.sql INSERT INTO page_sections (page_id, key, position, data)SELECT id, 'prose', 2, json('{"heading":"About","body":""}')FROM pages WHERE slug = '/about';
interface SectionProps<Data, Settings> { data: Data; // this section's validated value settings: Settings; // every site setting page: PageMeta; // slug, title, SEO of the page being rendered index: number; // position in the page's section list}overlayHeader is a render hint, not content. It tells the template that this
section sits under a transparent header. Nothing in the engine reads it; your
layout does.
A section never reads another section’s data. If two sections need the same
value, it is a setting. A call to action shared by three blocks belongs in
site.cta, with each section falling back to it:
const button = data.button.link ? data.button : settings.site.cta;A section component does not set its own anchor id. The engine’s
Sections.astro already wraps each one in <div id="ounce-{key}">, and
PRIMARY KEY (page_id, key) guarantees that id is unique on the page. Adding a
second id inside the component gives you two targets for the same block, and a
menu item generated from the section points at the wrapper, not at yours.
A repeating block is a list field, not two sections. The database will not
let you put two prose sections on one page.
A section type says whether it belongs in a menu. nav: false on the entry
means the section is never offered as a navigation target, which is right for a
hero or a call to action. nav: { default: false } means it is navigable but
starts out of the menu. Per page, an editor overrides either. See
Navigation.
Rendering
Section titled “Rendering”The engine ships the loop. Your template supplies the map from section key to component and hands both to it:
---import Sections from '@ouncepage/core/Sections.astro';import { sectionTypes } from './sections';
const { view } = Astro.props;---
<Sections view={view} types={sectionTypes} />import { defineSection } from '@ouncepage/core/sections';import Prose from './components/Prose.astro';
export const sectionTypes = { prose: defineSection({ component: Prose }),};It renders each key in view.keys, in stored order, wrapped in
<div id="ounce-{key}" data-ounce-section="{key}">. That wrapper is what a menu
item’s anchor points at and what a scrollspy watches, so a template that
writes its own loop instead loses both. Each component receives
{ data, settings, page, index }.
The registry is a Partial<Record<...>> on purpose: a template may legitimately
not implement every section type your schema defines, and skipping is better
than crashing. It is also why a missing component is silent, so check a new
section type renders before you call it done.
Accordion example
Section titled “Accordion example”The compact form of a repeating section, as faqEntry expects to be rendered:
<div class="divide-y border-t"> {faq.items.map((item, index) => ( <details name="faq" open={index === 0} class="group"> <summary class="flex cursor-pointer list-none items-start justify-between gap-4 py-5 [&::-webkit-details-marker]:hidden"> <h3>{item.question}</h3> <span class="transition group-open:rotate-45" aria-hidden="true">+</span> </summary> {item.answer && <div class="prose pb-5" set:html={item.answer} />} </details> ))}</div>Three things in there are load-bearing:
<summary>must be the first child of<details>. Wrap it in anything, even a<dt>for definition-list semantics, and the browser stops treating it as the label: it renders its own “Details” marker and drops your question into the body. It parses, it type checks, and it looks completely broken.list-noneand[&::-webkit-details-marker]:hiddenremove the default triangle in every engine.nameon<details>makes the group exclusive, so opening one closes the rest, with no JavaScript.
No enabled filtering appears here, because the engine already removed disabled
items on the way out.
File layout
Section titled “File layout”Directorysrc/site
- sections.ts the entries: schema, form, defaults
Directorysrc/templates/v1
- sections.ts key to component
- Sections.astro the loop
Directorycomponents/
- Prose.astro
The split matters. src/site is content shape, which the admin reads.
src/templates is presentation, which only the public site reads. Keeping them
apart is what lets you build a second template against the same content.