Skip to content

Navigation

A template declares where a menu can go. The engine decides what goes there. Neither one names the other, which is what lets a page override the site menu without the template knowing that pages can do that.

A region is a slot in your template. You declare them in defineSite:

src/site/ounce.config.ts
export default defineSite({
brand: 'Example CMS',
settings,
sections,
navigation: {
regions: [
{ id: 'header', label: 'Header', default: 'menu:navigation', pageMenu: true },
{ id: 'footer', label: 'Footer', default: 'menu:footerNavigation' },
],
anchors: [
{ id: 'top', label: 'Top of the page', position: 'start', derived: false },
{ id: 'contact', label: 'Contact', position: 'end' },
],
},
});
Field Type Meaning
id string The key the template reads out of view.menus
label string What the admin calls this region
default `menu:${string}` | 'none' Which menu fills it when the page has no opinion
pageMenu boolean Whether a page may replace what is in it

The key in menu:navigation is a setting key, and that setting must be a menuEntry(). 'none' leaves the region empty by default, which is useful for a region only some pages fill.

view.menus is a Record<string, MenuLink[]> keyed by region id. Every region you declared is present, possibly as an empty array.

src/templates/v1/Main.astro
---
import type { Menus } from '@ouncepage/core/navigation';
interface Props {
menus: Menus;
}
const { menus } = Astro.props;
const items = menus.header ?? [];
---
{items.length > 0 && (
<nav aria-label="Main">
{items.map((item) => (
<a href={item.href} aria-current={item.current ? 'page' : undefined}>{item.label}</a>
))}
</nav>
)}

Each link is { label, href, current }. current is true only for a link to the page being rendered, and only when that link has no # in it, because an anchor link on the current page is the scrollspy’s business rather than the server’s.

A menu item stores a page id, not a path:

{ label: 'Story', page: 1, anchor: 'ounce-ourStory', href: '', enabled: true }

Exactly one of page and href is set, which the schema enforces. page is a reference resolved to the page’s current slug at render time, so renaming a page keeps every link to it working. href is an external address and is stored verbatim. anchor only applies with page.

The engine resolves references with a map of every page id to its slug. If you call resolveMenus yourself, pass it, or every page-referencing item resolves to an empty href and gets dropped:

import { resolveMenus } from '@ouncepage/core/navigation';
import { pageRoutes } from 'ounce:config';
const menus = resolveMenus(ounce.navigation, settings, page, anchors, await pageRoutes());

Sections.astro wraps every rendered section in <div id="ounce-{key}">, so each section on a page has an anchor without the template doing anything. A menu item can point at one by setting anchor to that id.

A section type that should never appear in a menu says so on its entry:

src/site/sections.ts
banner: entry({
schema: bannerSchema,
nav: false,
form: { /* ... */ },
defaults: { /* ... */ },
}),
nav Meaning
omitted The section is navigable and is in the menu by default
{ default: false } Navigable, but starts out of the menu
false Never navigable. The editor is not offered the choice.

nav sets the type’s default. Per page, an editor can include or exclude a section and rename its menu label; those two choices live on the page’s section row, not on the type, and a page that has never been touched follows the type.

anchors in the navigation config declares the ids your template renders itself, outside of any section: a <body id="top">, a contact block in the footer. position puts them before or after the section links. derived: false keeps an anchor out of the generated list while still offering it in the admin’s anchor picker, which is right for something like “Top of the page” that no menu should list but a hand-built menu might want.

A region marked pageMenu: true can be overridden by the page being rendered. The page picks one mode on its Navigation tab:

Mode What fills the region
(default) The region’s default menu
Links to its own sections One link per navigable section on this page, plus the declared anchors
A menu of its own A list of links this page alone owns
No menu Nothing

The page never says where. It says what, and every pageMenu region on that page gets it. A template that moves its menu, or grows a second one, needs no content change.

A page with no page-menu regions is not offered the tab at all, so a site that never declared one does not gain an editor control it cannot use.

pages.navigation holds the mode and, for A menu of its own, the items:

{ "mode": "own", "items": [{ "label": "Home", "page": 1, "anchor": "", "href": "", "enabled": true }] }

mode is '', 'sections', 'own' or 'none'. Items use the same shape as a site menu, so the two editors and the same validation are shared.

An anchor is stored as a plain string. Nothing verifies that the id exists on the target page, because the page may not have that section yet, or the id may belong to the template rather than to a section. A menu item pointing at a missing anchor renders a link that scrolls nowhere.

The ids on one page come from three places: ounce-{key} from Sections.astro, whatever your template hardcodes, and the anchors you declared. Only the first and third are offered in the admin picker. If your template adds an id and does not declare it, an editor cannot select it.

Can: declare any number of regions, fill each with any menu, let a page take over the ones you marked, and generate a menu from the sections that are actually on a page.

Cannot: let a page choose which region its menu lands in, nest a menu, or point a menu item at anything other than a page, a page plus an anchor, or an external address.