Architecture
Most confusion about Ounce comes from one question: when I want to change how something looks or behaves, which file do I open? There are three layers, and each one has a job the other two cannot do.
Three layers
Section titled “Three layers”-
The field type is the engine’s layer. It owns one input: how it draws in the admin, how it reads back off a form, how it sanitises, whether the assistant may write it.
richtext,image,toggleandlistare field types. You can add your own; see Field types. -
The section type is your site’s layer. It pairs a Zod schema, a field list and a defaults object, and it lives in
src/site/sections.ts. It decides that a Benefits block has an eyebrow, a heading and a repeating list of features. -
The template component is your design’s layer. It receives the parsed data and renders HTML. It lives in
src/templates/, and Ounce never looks inside it.
The layers only touch through data. A field type knows nothing about Benefits. A section type knows nothing about the markup. A template component knows nothing about forms or the database.
Ownership
Section titled “Ownership”Ships in @ouncepage/core |
You write | |
|---|---|---|
| Field types | The built-ins | Your own, registered through a plugin |
| Section types | The factories: seoEntry, faqEntry, listSection and the rest |
One entry per block your site has |
| Templates | Sections.astro, which loops and wraps |
Every component it renders |
| Routing | /[...path], /404, /media/[...key], every /admin route |
Extra pages, if you want any |
| Middleware | Auth, security headers, the CSP | Yours runs after, if you write one |
| Tables | settings, pages, page_sections, revisions, media, media_uses, redirects, editors and mcp_tokens |
Your own tables, if any |
The split is not arbitrary. Anything that has one correct implementation lives in the package, so upgrading gets you the fix. Anything where sites genuinely differ lives in your project, so upgrading never overwrites your decisions.
Field lifecycle
Section titled “Field lifecycle”Take benefits.features.0.title on the home page. Here is every stop it makes.
-
You declare it. In
src/site/sections.ts, the Benefits entry has a schema withfeatures: z.array(z.object({ title: required, ... }))and a field list with{ name: 'title', label: 'Title', type: 'text' }. -
The admin draws it.
/admin/pages/1builds one form from the page’s section list. Thelistfield type renders a repeater; thetextfield type renders one input inside it, namedfeatures.0.title. -
The editor types and saves.
readPageFormturns the flatFormDataback into nested objects, walking the field list rather than guessing, which is why a dot in a field name breaks the shape. -
Ounce validates and stores.
sanitizeHtmlruns on every rich text value, thensafeParseruns the whole section object against your Zod schema. Only then does it write one JSON blob topage_sections, and one row torevisionsrecording the field-level diff. -
A visitor requests the page.
loadView('/')reads the settings and the page’s sections in a single D1 batch.stripDisabledremoves every array item withenabled === false, at any depth, before anything sees the data. -
Your component renders it.
Sections.astrolooks upbenefitsin your registry, wraps the result in<div id="ounce-benefits" data-ounce-section="benefits">, and hands your component{ data, settings, page, index }.
Two things in that list surprise people.
stripDisabled means your template never sees a disabled item. You do not
filter on enabled yourself, and if you do you are writing dead code. The
toggle is honoured before render.
The wrapper div is not decoration. The admin preview scrolls to a section
by querying [data-ounce-section="..."]. That is why Sections.astro ships in
the package rather than in your template: a hand-written loop that forgets the
attribute produces a preview that quietly stops scrolling, with no error
anywhere.
Virtual modules
Section titled “Virtual modules”The integration registers Vite modules so the engine and your site can see each other without importing each other’s files.
ounce:config re-exports everything defineSite() returned. Admin routes read
your content through it, and so can you:
import { loadView, listPages, brand } from 'ounce:config';ounce:page and ounce:notfound resolve to the two components you named in
astro.config.mjs. The engine renders your pages through them without knowing
their paths, which is the whole reason the package can stay free of your
template.
Before these existed, an admin preview route imported a component through the
consuming site’s @template alias. It typechecked, because the site’s tsconfig
supplied the alias. It would have broken for anyone else installing the package.
Reading content
Section titled “Reading content”Everything defineSite() returns is available to your own routes and scripts.
The ones you will reach for:
| Function | Returns |
|---|---|
loadView(path) |
Settings plus one page’s sections, in one D1 batch |
loadNotFound() |
The same shape for the not-found page |
loadSettings() |
Just the settings |
listPages() |
Page rows for a menu or a sitemap |
findRedirect(path) |
The new path after a rename, or null |
loadView returns null for a path with no page, which is how the catch-all
route decides between a redirect, a 404 and a render.
The View
Section titled “The View”Everything that loads content returns the same shape, and it is the only prop
your Page.astro and NotFound.astro receive.
interface View<Settings, Sections> { page: PageMeta; settings: Settings; sections: Partial<Sections>; keys: (keyof Sections & string)[]; menus: Menus; beacons: AnalyticsBeacon[];}| Key | Is |
|---|---|
page |
The row’s own metadata, below |
settings |
Every global entry, parsed, with disabled items stripped |
sections |
Only the sections this page actually has. Partial, so read through keys |
keys |
The section keys in stored order. This is the render order |
menus |
Record<string, MenuLink[]>, one entry per navigation region |
beacons |
The analytics scripts the enabled plugins asked for |
sections is partial and keys is not, which is the pairing that matters.
Iterating Object.keys(view.sections) gets you the same set in the wrong order.
Sections.astro walks keys, and so should anything you write.
A MenuLink is { label, href, current }, already resolved: page ids have
become paths, disabled items are gone, and current is set against this page’s
slug. Anchors within the page arrive as href values containing #, and those
are never current.
PageMeta
Section titled “PageMeta”interface PageMeta { id: number; slug: string; title: string; seoTitle: string; seoDescription: string; socialImage: string; ogTitle: string; ogDescription: string; extras: Record<string, unknown>; navigation: PageNavigation; enabled: boolean; deletedAt: string | null; updatedAt: string; updatedBy: string | null;}This is the type SectionProps refers to and never spells out. extras holds
whatever the page tabs your plugins registered have written. navigation is the
page’s own menu override, { mode?, items? }, which menus has already been
resolved from, so a template reads menus and leaves this alone.
The not-found view
Section titled “The not-found view”NotFound.astro receives a View like any other, from loadNotFound(). It
looks for a page whose slug is /404 and returns that view if it finds one, so
the not-found page is editable in the admin like the rest of the site.
When no such page row exists it synthesises one instead, and the difference is worth knowing before you write the template:
page.idis0andpage.titleisPage not found. Every other string onpageis empty.sectionsis{}andkeysis empty. RenderingSectionsis safe and produces nothing, so a template that only renders sections renders a blank page.settingsandmenusare fully populated either way, so the header, the footer and the navigation are always there.
Branch on view.keys.length if you want hard-coded fallback copy in the
no-row case. notFound is optional in the integration options and falls back
to your page component, which works only if that component handles an empty
keys.
Inferred types
Section titled “Inferred types”You never hand-write a content type. Infer maps a registry to the shape its
schemas parse to:
import type { Infer } from '@ouncepage/core/site';import { sections } from './sections';import { settings } from './settings';
export type Settings = Infer<typeof settings>;export type Sections = Infer<typeof sections>;export type Content = Settings & Sections;Change a schema and every component that reads it fails to compile. That is the point of putting Zod at the centre: one declaration drives the form, the validation, the stored shape and the types your templates see.
Field names
Section titled “Field names”A field name becomes part of the form input’s name, and Ounce splits on dots
to rebuild the nested object. A field called post.code therefore saves as
{ post: { code: ... } } and vanishes from your schema’s view. Use
post_code. Nothing warns you.