Menus
@plumix/plugin-menu is site navigation. An editor builds a menu in the admin from links to entries, terms and custom URLs, nests the links into a tree, and assigns the menu to a location such as primary or footer. A theme template declares the locations it renders and receives each one’s menu as a resolved tree.
Overview
Section titled “Overview”The plugin exports a factory named menu, so you call it. Its one option, locations, names the navigation slots your theme renders. menu() with no locations still installs the menu builder, but there is nowhere to assign a menu to.
It adds no tables. A menu is a row in terms with the taxonomy menu, a menu item is a row in entries with the type menu_item, and membership is a row in entry_term. Both registrations are isPublic: false, so they stay out of the generic Entries and Terms screens and never get a public URL. Installing the plugin needs no migration.
What it registers:
- An admin page at
/menus, under Appearance, for building menus and assigning them to locations. - A
menustemplate dep, so a template can declare the locations it renders. - Server functions,
getMenuForLocationandgetMenuByName, for code that resolves a menu outside a template. - Two filters,
menu.itemandmenu.tree, and two events,menu.savedandmenu.deleted.
Every screen and procedure checks term:menu:manage, the manage capability the menu taxonomy mints. It maps to the editor role, so editors and admins can change the site’s navigation. Every capability of menu_item and of the menu taxonomy maps to the editor role too, so core’s generic entry and term procedures reach menus at the same tier.
Quickstart
Section titled “Quickstart”Install the package with whichever package manager the project uses:
pnpm add @plumix/plugin-menunpm install @plumix/plugin-menuyarn add @plumix/plugin-menubun add @plumix/plugin-menuThen:
-
Add
menu()to thepluginsarray with the locations your theme renders.import type { PlumixConfigInput } from "plumix";import { menu } from "@plumix/plugin-menu";export const plugins: PlumixConfigInput["plugins"] = [menu({locations: {primary: { label: "Primary", maxDepth: 2 },footer: { label: "Footer", description: "Links under every page" },},}),]; -
Restart the dev server. The plumix Vite plugin compiles the admin bundle when the dev server starts, so the new page appears only after a restart.
Terminal window pnpm dev -
Build a menu. Open Appearance, then Menus, at
/_plumix/admin/menus. Create a menu, add links to it, drag them into order, and save. -
Assign it. Switch to Manage Locations and pick the menu for
primary. -
Render the location from a template, as Rendering a menu shows.
Locations
Section titled “Locations”A location is a slot your theme renders, and the site declares its locations in menu({ locations }). The key is the location id, and the value carries a label and an optional description for the admin’s Manage Locations tab, and an optional maxDepth.
A location id starts with a lowercase letter and contains only lowercase letters, digits and hyphens, up to 64 characters. The label must not be empty. maxDepth, when set, is a whole number from 1 to 20. Any of these mistakes throws at boot with an error that names the location.
maxDepth is how many levels the theme renders in that slot. A header that shows a single dropdown level declares maxDepth: 2:
import { menu } from "@plumix/plugin-menu";
menu({ locations: { header: { label: "Header", maxDepth: 2 }, footer: { label: "Footer", maxDepth: 1 }, sidebar: { label: "Sidebar" }, },});The location owns the limit; a menu has none of its own. A menu’s limit is the smallest maxDepth among the locations it is assigned to, or 5 when none of them declares one. The plugin works it out from the current config on every read and stores nothing, so changing a location’s maxDepth or assigning a menu to another location takes effect at once.
The admin can assign a menu only to a declared location. Each location holds at most one menu, and one menu can fill several locations. The plugin stores the binding in the site’s settings, where it outlives the config. A location you remove and later declare again gets its menu back.
Building a menu
Section titled “Building a menu”The menu builder has two tabs. Edit Menus lists the site’s menus and edits one at a time. Manage Locations assigns a menu to each declared location, or leaves the location unassigned.
A menu item links to one of three things:
- An entry. There is one picker tab per eligible entry type.
- A term. There is one picker tab per eligible taxonomy.
- A custom URL. This tab is always last.
Each entry and term tab searches as you type. Entries match by title, and terms by name or slug. The tab lists the first 20 matches, so type more of a title to narrow it down. Click a match, or move to it with the arrow keys and press Enter, to add it to the end of the menu. The search keeps its text and focus, so you can add several in a row. Drafts are listed too: a draft item stays off the site until its entry is published.
A linked item’s label follows the linked entry’s title or term’s name, so a rename shows up in the menu without editing it. Type a navigation label in the item’s details to use your own instead, and clear it to go back to the linked title.
Items nest into a tree up to the menu’s limit, which the editor shows under the location checkboxes, such as “Up to 2 levels, set by the Header location”. Dragging an item past that level stops it at the deepest level allowed, and the server refuses a save that nests an item deeper than the limit. When a limit drops after the menu was built, because a location’s maxDepth changed or the menu was assigned to a stricter location, the items already deeper stay. The editor marks each one “Hidden in” the locations that don’t render it, and the menu still saves, so long as no item moves further down. No menu nests deeper than 20 levels. A single save holds at most 500 items.
A custom URL is either relative (starting with /, #, ?, ./ or ../) or absolute with an http, https, mailto, tel or sms scheme, the same schemes every Plumix link accepts (LINK_SCHEMES from plumix/blocks). Saving the menu refuses anything else, including javascript:, protocol-relative //host URLs and a bare mailto:, tel: or sms:.
Two editors can have the same menu open. Each save carries the etag the menu was loaded with as ifMatch, so the server refuses the second save and the editor offers to keep your version or load theirs. It never silently overwrites the first. An editor who opens a menu also takes a lease on it: while another user holds it, the menu opens read-only with a banner naming them, and Take over makes it editable.
When an item’s linked entry or term no longer resolves, the editor marks the item as a broken link and offers two fixes. Re-link points it at another entry or term. Convert to Custom URL turns it into a custom link, which needs an address before the menu can be saved. Each save stores the label and URL its linked entry or term had, so a broken item keeps its name and Convert to Custom URL fills in the last known address. A snapshot is only as fresh as the last save of the menu.
Which entry types appear
Section titled “Which entry types appear”An entry type or taxonomy appears in the picker when it is public. A type registered with isPublic: false has no URL to link to and never appears. A public one can opt out with isShownInMenus: false. menuPickerLabel renames its picker tab, which otherwise uses the type’s plural label:
import { definePlugin } from "plumix/plugin";
export const recipes = definePlugin("recipes", { setup: (ctx) => { ctx.registerEntryType("recipe", { label: "Recipes", isPublic: true, menuPickerLabel: "Recipe pages", });
ctx.registerTermTaxonomy("diet", { label: "Diets", entryTypes: ["recipe"], isPublic: true, isShownInMenus: false, }); },});Both options are this plugin’s additions to the registration options, so they type-check once @plumix/plugin-menu is a dependency.
isShownInMenus: false does more than hide the picker tab. Links already in a menu to that type stop rendering too, because the picker, the editor and the public render all ask the same question.
Lookup adapters other than entries and terms, such as a plugin’s own, stay out of the picker unless their registration sets menuPicker: { tabLabel }.
Rendering a menu
Section titled “Rendering a menu”A template declares the locations it renders under menus and receives them keyed by location id. Each value is the menu assigned to that location, or null when none is:
import { defineTemplate } from "plumix/theme";
import type { ResolvedMenu, ResolvedMenuItem,} from "@plumix/plugin-menu/server";
function MenuList({ items }: { items: readonly ResolvedMenuItem[] }) { return ( <ul> {items.map((item) => ( <li key={item.id} className={item.cssClasses.join(" ")}> <a href={item.href} target={item.target} rel={item.rel} aria-current={item.isCurrent ? "page" : undefined} > {item.label} </a> {item.children.length > 0 ? <MenuList items={item.children} /> : null} </li> ))} </ul> );}
function Nav({ menu }: { menu: ResolvedMenu | null | undefined }) { return menu ? ( <nav> <MenuList items={menu.items} /> </nav> ) : null;}
export const page = defineTemplate({ menus: ["primary", "footer"], render: ({ menus }) => ( <> <Nav menu={menus?.primary} /> <main /> <Nav menu={menus?.footer} /> </> ),});The type import from @plumix/plugin-menu/server is what the sample uses for its props. The plugin’s package declares the menus key on the template, so installing it is enough to make the key valid.
A ResolvedMenu carries the menu’s termId, name, slug and items. Each item carries:
labelandhref, the text and address to render.source, which says whether the item links to anentryorterm(with itsid) or iscustom.isCurrent, which is true when the item points at the page being rendered. A custom URL counts when it matches the request’s path.isAncestor, which is true when any item below it in this menu is current.target,relandcssClasses, from the stored item. The admin does not edit these today, and amenu.itemhandler can set them.children, the nested items, already resolved the same way.
A location renders as many levels as its maxDepth says. The plugin doesn’t resolve the items below that, so they cost nothing, and the template never receives them. A location with no maxDepth, and a menu looked up by name, render every level.
The plugin resolves links to entries and terms when the page renders, not when you save the menu. An entry or term item shows its own label when the editor set one, and the linked entry’s title or the term’s name otherwise. A label of only spaces counts as unset. Renaming a page renames every menu item that follows its title, with no need to re-save the menu, and the item’s address follows the page whichever label it shows. An entry item shows only while its entry is published, so a draft added to a menu appears once it is published. The rendered menu leaves out an item whose target is unpublished, deleted, or of a type that is no longer eligible. Every item nested under it goes too.
The plugin batches resolution. It reads every location a request asks for, from templates and from the functions below, in one settings query and one pass over the menus’ items. Repeat calls within the request reuse that result.
With a CDN configured, a cached page that renders a menu is purged when a save changes that menu or the menu is deleted, when a location it renders is assigned or cleared, and when an entry or a term the menu links to changes, such as a category renamed or a linked draft published. A page that rendered an empty location is purged too once a menu is assigned there.
Resolving a menu in server code
Section titled “Resolving a menu in server code”Outside a template, the /server entry resolves menus from any code holding the request context, such as a route handler, a block loader or an event handler. It imports no admin code.
import type { AppContext } from "plumix/plugin";
import { getMenuByName, getMenuForLocation } from "@plumix/plugin-menu/server";
export async function navFor(ctx: AppContext) { const primary = await getMenuForLocation(ctx, "primary"); const legal = await getMenuByName(ctx, "legal-links"); return { primary, legal };}getMenuForLocation returns the menu assigned to a location, or null. getMenuByName looks a menu up by its slug, not its display name, and returns null when no menu has that slug. Both have batched forms, getMenusForLocations(ctx, locations) and getMenusByName(ctx, slugs), that return a record keyed by what you passed.
Events and filters
Section titled “Events and filters”The /server entry exports the plugin’s events and filters as one namespace, menu, so another plugin subscribes with ctx.filters.add(menu.tree, …) or ctx.events.on(menu.saved, …) with no declare module. It lives there rather than on the package root because the root’s menu() factory already takes the name.
menu.item runs once for each resolved item. Children are resolved first, so a handler sees an item’s subtree already filtered. Opening every external link in a new tab:
import { definePlugin } from "plumix/plugin";
import { menu } from "@plumix/plugin-menu/server";
export const externalLinks = definePlugin("external-links", { setup: (ctx) => { ctx.filters.add(menu.item, (item) => item.source.kind === "custom" && item.href.startsWith("http") ? { ...item, target: "_blank", rel: "noopener" } : item, ); },});menu.tree runs once on the whole list of top-level items after assembly. Its second argument is { location, termId }, where location is the slot being rendered, or null when the menu was looked up by name. One handler can therefore treat the footer differently from the header.
menu.saved is an event emitted after every successful save, including a save that changed nothing, with { termId, addedIds, removedIds, modifiedIds } and the request context. A handler that clears a cache can run on every call without inspecting the payload. A handler that throws does not undo the save.
menu.deleted is an event emitted after a menu and its items are deleted, with { termId, slug } and the request context. Assigning or clearing a location, and deleting a menu that was assigned to one, emit core’s settings.groupChanged event from plumix/plugin for the menu_locations group. Subscribe to settings.for("menu_locations").groupChanged to hear only those.
Related
Section titled “Related”Overview covers installing plugins in general and explains why a new admin page needs a restart. Entry Types and Taxonomies and Terms document the isPublic and label options the picker reads. Themes and Templates cover template deps and the ctx a template receives. Access & Identity covers the roles that hold term:menu:manage and how a role you declare grants it.
Next steps
Section titled “Next steps”Declare menus on the templates your theme’s layout uses. Templates covers the builders those templates are bound with, and Template Hierarchy covers which one a request reaches.
If the site links to pages, install Pages so they appear as a picker tab. Blog does the same for posts and their categories and tags.