Skip to content

Site config

ounce.config.ts default-exports the result of defineSite. It is the only file the integration reads, and everything the admin knows comes from it.

src/site/ounce.config.ts
import { defineSite } from '@ouncepage/core';
import { cloudflareAnalytics } from '@ouncepage/cloudflare-analytics';
import { sections, settings } from './schema';
export default defineSite({
brand: 'Example CMS',
settings,
sections,
home: '/admin/pages',
composeSections: true,
plugins: [cloudflareAnalytics()],
csp: {
img: ['https://api.mapbox.com'],
frame: ['https://booking.example.com'],
},
editorStyles: [
{ className: 'block', display: 'block' },
{ className: 'text-yellow-400', label: 'Brand', color: '#fbbf24' },
],
});
Option Type Default Meaning
brand string required The name in the admin sidebar
settings Record<string, Entry> required Global singletons, keyed by setting key
sections Record<string, Entry> required Section types, keyed by section key
home string /admin/analytics Where /admin redirects
plugins Plugin[] [] See Plugins
composeSections boolean true Whether editors may add and remove sections on a page
beaconInDev boolean false Whether analytics beacons load under astro dev
navigation NavigationConfig no regions Menu regions and fixed anchors. See Navigation
mark BrandMark the Ounce mark The logo in the admin sidebar
logos LogoService | null null Supplies plugin logos by domain
csp CspHosts {} Extra hosts for the public Content-Security-Policy
editorStyles EditorStyle[] [] Custom styles in the rich text toolbar

defineSite checks the two registries before anything else runs, and throws rather than booting into a shape that would go wrong quietly later. Each of these used to be a silent misbehaviour:

Refused Because
A key in both settings and sections The two are merged into one lookup, so the section wins everywhere and the setting becomes unreachable
A dot in an entry key A key prefixes an admin form input name, where a dot means nesting, so the saved value is reshaped
An entry key starting with plugin: Reserved for plugin settings, which share the settings table
A dot in a field name, at any depth Same reshaping as an entry key, and harder to spot
Two settings built with seoEntry(), or two with openGraphEntry() Ounce resolves page metadata by kind and would use the first, leaving the rest editable but ignored

Navigation regions are checked the same way: a dot in a region id, or the same id twice, both throw. See Navigation.

A setting built with menuEntry() is a menu, and Ounce works that out from the entry rather than from a list you keep in step. Menus are edited at /admin/navigation instead of /admin/settings, and are guarded by navigation.edit rather than content.edit. The first one declared is the default.

composeSections: false means a page’s section list is fixed by your migrations. Editors fill sections in; they cannot add or remove them.

logos draws a mark beside every plugin that declares a domain. Ounce ships one implementation, domainLogos(token, size?) from @ouncepage/core/logos, which takes a publishable Logokit token. Any object with a host and a url(domain) works, and the host is what Ounce adds to the admin’s img-src, so a logo service can never widen the policy by more than the origin it names.

csp is the one part of the security headers your site owns. Ounce’s middleware writes a policy that already allows 'self' and every host the analytics plugins you installed need for their beacons; csp adds the hosts only your templates know about. Each key is a directive, and the values are appended: script, style, img, font, connect and frame. frame-src is the exception: it starts at 'self' rather than empty, because the admin preview renders in an iframe.

csp: { img: ['https://api.mapbox.com'], frame: ['https://booking.example.com'] }

The policy is only sent on a production build, so a missing host shows up after a deploy and not in dev. Check it with curl -I against astro preview or wrangler dev, where import.meta.env.PROD is true.

The admin sidebar shows the Ounce mark next to your brand name. Point mark at your own logo to replace it:

mark: { src: '/logo.svg', label: 'Acme' },
Field Type Meaning
src string Any image URL. A path in public/, or a data: URI.
label string Alt text. Leave it out and the image is marked decorative, which is right when your brand name sits beside it.

src is rendered as an <img>, so a full-colour logo works as-is. The built-in mark inherits the admin’s text colour; your own will not.

An absolute URL is added to the admin’s img-src for you. Without that it would be blocked by the admin Content-Security-Policy and you would see a broken image with nothing in the console to explain it.

Every value in settings and sections is an Entry: a Zod schema, a form, and defaults matching the schema.

src/site/settings.ts
import { z } from 'zod';
import { entry, link, required, text } from '@ouncepage/core';
export const settings = {
site: entry({
schema: z.object({
brand: required,
cta: z.object({ link, label: text }).default({ link: '', label: '' }),
}),
form: {
title: 'Site',
blurb: 'Applies to every page.',
fields: [
{ name: 'brand', label: 'Brand name', type: 'text' },
{
name: 'cta',
label: 'Default call to action',
type: 'group',
fields: [
{ name: 'label', label: 'Label', type: 'text' },
{ name: 'link', label: 'Link', type: 'url' },
],
},
],
},
defaults: {
brand: 'Example',
cta: { link: '', label: '' },
},
}),
};

entry is an identity function. It exists so TypeScript infers defaults from schema and tells you when the two disagree.

Exported from @ouncepage/core so your schemas match the ones the engine’s sanitiser and form reader expect.

Helper Is
text z.string().trim()
required text.min(1)
link text refined to https://, mailto:, tel:, / or #. No default
requiredLink required with the same refinement
linkSchema z.object({ link, label: text })
eyebrow text.default('')
cta z.object({ link, label: text }) with an empty default
toggle a boolean that reads 'true' and 'false' from form data
enabled toggle, defaulting to true
enabledField a ready-made Field for an enabled toggle
buttonGroup(name, label, options?) a ready-made Field for a link and a label

Some entries come out the same on every site. Rather than retyping the schema, the form and the defaults, compose them:

src/site/settings.ts
import { announcementEntry, footerEntry, menuEntry, socialEntry } from '@ouncepage/core/chrome';
import { openGraphEntry, seoEntry } from '@ouncepage/core/seo';
export const settings = {
seo: seoEntry({ defaults: { title: 'Example', description: '...', keywords: [] } }),
openGraph: openGraphEntry({ defaults: { image: '', title: '', description: '' } }),
announcement: announcementEntry(),
navigation: menuEntry({
title: 'Main navigation',
defaults: { items: [{ link: '/', label: 'Home', enabled: true }] },
}),
footer: footerEntry({ defaults: { text: '&copy; {year} Example.' } }),
};

Eight of these ship with Ounce, including listSection for any section that is a heading plus a repeating list. Signatures and worked examples are in Entry factories.

They return ordinary entries. Nothing downstream, not the form reader, the diff, the revision trail or the assistant, can tell a composed entry from a hand-written one, and each takes title and blurb to override what the admin shows.

The return value is re-exported by the ounce:config virtual module. Import from there, not from your config file, so your imports keep working if the file moves.

import { loadView, loadNotFound, listPages, brand } from 'ounce:config';

Reading content:

Export Returns
loadView(path) Settings plus one page’s sections, in a single D1 batch
loadNotFound() The same for /404
loadSettings() Every setting
loadSetting(key) One setting
listPages() Page rows, for a listing
loadPage(id) One page’s own fields
pageSlugs() Every slug, for cache purging
listPageSections(id) A page’s sections in order
lastUpdated() The most recent content write
invalidContent() Keys that failed safeParse on the last read

Writing content, all of which take an Author and record a revision: savePage, saveSetting, savePageSection, savePageSections.

Describing the admin: brand, home, entries, ounce, menus, menuKeys, settingKeys, sectionKeys, settingKeysInMenu, titleOf, blurbOf, nav(role), targetOf, SCOPE_LABELS, and the href builders pageHref, pageSectionHref, settingHref, menuHref, activityHref, resolveMenu.

You do not write the route. Ounce injects /[...path] and renders it through the component you named as page in astro.config.mjs. That component gets a View, which already carries the analytics beacons the enabled plugins asked for:

src/templates/Page.astro
---
import Sections from '@ouncepage/core/Sections.astro';
import Main from './Main.astro';
import { sectionTypes } from './sections';
import type { AnalyticsBeacon } from '@ouncepage/core/analytics';
import type { View } from '@ouncepage/core/content';
import type { Sections as SectionData, Settings } from '../site/schema';
interface Props {
view: View<Settings, SectionData>;
beacons?: AnalyticsBeacon[];
}
const { view, beacons = view.beacons } = Astro.props;
---
<Main settings={view.settings} page={view.page} beacons={beacons}>
<Sections view={view} types={sectionTypes} />
</Main>

view.beacons is resolved from the same settings rows the view already read, so a page costs one database round trip rather than two. Take them off the view rather than calling analyticsBeacons yourself, which reads the table again.

Sections ships in the package. It walks view.keys in order, looks each key up in the registry you pass as types, and wraps each one in the markup the admin preview needs to scroll to it. Write your own loop and the preview stops scrolling, with no error.

Two more components ship for the part of <head> every site writes the same way:

src/templates/Main.astro
---
import Seo from '@ouncepage/core/Seo.astro';
import Beacons from '@ouncepage/core/Beacons.astro';
---
<head>
<meta charset="utf-8" />
<Seo settings={settings} page={page} image={fallbackImage} />
<Beacons beacons={beacons} />
</head>

Seo writes <title>, the canonical link, the meta description and keywords, and the whole Open Graph and Twitter block. It resolves each value the way the admin’s own preview does: the page’s field, then the site setting, then the argument you passed. image is the last resort, for a template that wants its hero to stand in when nothing else is set.

It finds your settings by kind, not by name, so a setting built with seoEntry() supplies the browser title and description whatever key you filed it under, and one built with openGraphEntry() supplies the sharing values. Register neither and Seo still renders, using only the page’s own fields.

Beacons renders the script tags the enabled analytics plugins asked for, and nothing at all under astro dev unless beaconInDev says otherwise.

The full picture, including redirects, caching and the not-found path, is in Routing and middleware.