Navigation menus
Editor-managed site navigation (#466). A menu is a named, ordered tree of
items — "Main navigation", "Footer" — addressed by a stable key so a front end
asks for main without knowing an id.
Before this, Kiln had no navigation resource at all: categories are flat, and every headless front end had to hard-code its nav.
Modelling
KilnCMS.CMS.Menu | key, name, locale, description. Unique on (key, locale) per site. |
KilnCMS.CMS.MenuItem | label, position, parent_id (the tree), a destination, open_in_new_tab, visible. |
KilnCMS.CMS.Menus | Delivery-side resolution: the flat rows folded into a tree with live URLs. |
Menus are structure, not content: no workflow, no version history, no
soft-delete — the same lightweight treatment Category and TagGroup get.
Editors manage them at /editor/menus.
Destinations
link_type decides what an item points at:
:content— a reference to a content record (target_type+target_id, the same currencyRedirectandContentLinkspeak). The URL is computed at read time from the record's current published path, so renaming a slug moves the navigation with it and never leaves a dead link. This is the whole reason a reference is stored rather than a path.:url— an external or hand-written destination. Passed throughKilnCMS.HTMLSanitizer.safe_href/1on write, so ajavascript:trap can't be stored, let alone served to a front end that trusts the API.:none— a heading with no link (a section label in a mega-menu).
Switching link_type clears the destination the item no longer uses: an item
that kept both would carry two answers, and a consumer picking the "wrong" one
would follow a link the editor believes they deleted.
The tree
parent_id nests, position orders siblings. Two guards are enforced at write
time, on the writes that actually move an item:
- depth stops at
KilnCMS.CMS.MenuItem.max_depth/0(3 — section → group → link; deeper than that is a sitemap, not a menu), counting the subtree a move carries — checking only the moved node would land its leaves deeper than the cap and then leave them unmovable; - an item can be neither its own parent nor its own descendant, and can't be parented into a different menu.
Both are skipped when parent_id isn't changing. A depth check that fired on
every write would freeze a too-deep row: you couldn't rename it, and you
couldn't outdent it either, because outdenting is itself a write.
The read path is safe regardless of what is stored. Two editors re-parenting concurrently can commit a cycle (each validates against pre-commit state), but the walk descends only from the roots and emits each node under its single parent — so a cycle's members become unreachable and vanish from the served tree rather than looping.
That is no longer silent (#900). The builder shows a Detached items section listing anything no chain of parents reaches a root from, with a Move to top level button per item that breaks the cycle by making it a root; its children come back with it. Nothing is deleted and nothing else moves, so the editor can then drag or indent it where it belongs through the paths that do validate. Only the top level is offered as a destination on purpose: the item is unreachable precisely because no parent of it is currently trustworthy, and re-parenting to a chosen one could be another cycle.
The section covers any cause of orphaning, not just the race — a restore, a
direct UPDATE, or a parent_id pointing into a different menu. It is
deliberately a structural question and is not filtered by visibility: an item
an editor switched off, or one whose target is unpublished, is legitimately
absent from a rendered tree and is not detached.
The race itself is still open: closing it needs a recursive-CTE ancestry check
inside the write transaction, or SELECT … FOR UPDATE over the ancestor chain.
Making the outcome recoverable was the better first move, because it also covers
the causes a lock would not.
In the builder, drag reorders within a level and indent/outdent changes depth. Depth is buttons rather than drag on purpose: dropping into a sibling is a small target, ambiguous at the "after this / inside this" boundary, and unreachable from a keyboard — and this tree is the site's navigation.
Localization
A menu is per locale, exactly like content: variants share a key and
differ by locale, so /api/menus/main?locale=fr returns French labels and
French destinations.
This is deliberately the shape of the one-record-per-locale content model rather than a per-item translations map. Labels, ordering, and which items exist all differ between locales in practice ("Impressum" has no English sibling), and a map can only translate the first of those.
A missing locale variant falls back only along a chain someone configured —
the site's locale fallback chains (/editor/locales, e.g. fr-CA → fr) or a
request's ?fallback_locale= — and otherwise is a miss. Unlike content, a menu
never takes the implicit hop to the default locale on its own: silently serving
English navigation on a French page is a worse answer than serving none unless
the site said otherwise. The response's locale (and x-kiln-locale /
Content-Language) names the variant served, and a locale the site does not
run is 400 unsupported_locale rather than the default menu. See
api.md → Locale fallback.
Delivery
GET /api/menus → every menu's key/name/locale
GET /api/menus/:key → one resolved tree (the request's locale)
GET /api/menus/:key?locale=fr → the French variant{
"key": "main",
"name": "Main navigation",
"locale": "en",
"items": [
{
"id": "…",
"label": "Products",
"url": null,
"link_type": "none",
"open_in_new_tab": false,
"children": [
{"id": "…", "label": "Kilns", "url": "/kilns", "link_type": "content",
"open_in_new_tab": false, "children": []}
]
}
]
}Two rules apply to every resolved tree, and they are the reason this endpoint exists rather than a raw resource read:
- a
:contentitem'surlis the target's current published path — itspath_aliaswhen set, else/<prefix>/<slug>— so navigation never links to a URL that would immediately 301; - an item whose target isn't published, or that an editor has switched off, is omitted along with its children. A dropped section takes its links with it rather than promoting them to the top level, which is what "omit unpublished" has to mean visually.
There is a GraphQL twin with the same rules:
{ menu(key: "main", locale: "fr") { name items { label url children { label url } } } }It returns null for a locale the menu has no variant in, matching the REST
404.
Responses are public, max-age=60 with no Vary: the locale is part of the URL
(?locale= or a /fr/… prefix), never a header or a cookie, so the URL is the
whole cache key.
The stored Menu/MenuItem rows are deliberately not on the auto JSON:API
or GraphQL surfaces. They carry references rather than URLs — and, more to the
point, they carry items an editor has hidden and items pointing at unpublished
content, which is exactly what the two rules above exist to withhold. Serving
them raw would publish the label and target id of an unannounced page to any
anonymous caller.
Authorization
Menus are world-readable: navigation is rendered on every public page, and a headless front end fetches it anonymously. What an item points at is still gated by the resolution rules above.
Writes follow the taxonomy pattern: editors create and edit menus and items, deleting a menu is admin-only (it takes every item with it), and a read-scoped API key can never write.