Skip to content

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.

  1. 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, toggle and list are field types. You can add your own; see Field types.

  2. 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.

  3. 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.

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.

Take benefits.features.0.title on the home page. Here is every stop it makes.

  1. You declare it. In src/site/sections.ts, the Benefits entry has a schema with features: z.array(z.object({ title: required, ... })) and a field list with { name: 'title', label: 'Title', type: 'text' }.

  2. The admin draws it. /admin/pages/1 builds one form from the page’s section list. The list field type renders a repeater; the text field type renders one input inside it, named features.0.title.

  3. The editor types and saves. readPageForm turns the flat FormData back into nested objects, walking the field list rather than guessing, which is why a dot in a field name breaks the shape.

  4. Ounce validates and stores. sanitizeHtml runs on every rich text value, then safeParse runs the whole section object against your Zod schema. Only then does it write one JSON blob to page_sections, and one row to revisions recording the field-level diff.

  5. A visitor requests the page. loadView('/') reads the settings and the page’s sections in a single D1 batch. stripDisabled removes every array item with enabled === false, at any depth, before anything sees the data.

  6. Your component renders it. Sections.astro looks up benefits in 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.

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.

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.

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.

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.

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.id is 0 and page.title is Page not found. Every other string on page is empty.
  • sections is {} and keys is empty. Rendering Sections is safe and produces nothing, so a template that only renders sections renders a blank page.
  • settings and menus are 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.

You never hand-write a content type. Infer maps a registry to the shape its schemas parse to:

src/site/schema.ts
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.

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.