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.
Example
Section titled “Example”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' }, ],});Options
Section titled “Options”| 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 |
What refuses to start
Section titled “What refuses to start”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.
Branding the admin
Section titled “Branding the admin”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.
Entries
Section titled “Entries”Every value in settings and sections is an Entry: a Zod schema, a form,
and defaults matching the schema.
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.
Schema helpers
Section titled “Schema helpers”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 |
Entry factories
Section titled “Entry factories”Some entries come out the same on every site. Rather than retyping the schema, the form and the defaults, compose them:
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: '© {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.
Return value
Section titled “Return value”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.
Rendering a page
Section titled “Rendering a page”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:
---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.
The head
Section titled “The head”Two more components ship for the part of <head> every site writes the same
way:
---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.