--- title: Menu item types description: Register typed menu item types from a content plugin, so menus and sitemaps link to its records through URL patterns the host can override. section: plugins order: 35 --- # Menu item types A menu entry or a sitemap row often points at content another plugin owns: a blog post, a category, "all posts". In WinterCMS the content plugin answered three events for that, `pages.menuitem.listTypes`, `pages.menuitem.getTypeInfo` and `pages.menuitem.resolveItem`. In SummerCMS it registers typed menu item types instead, and the [signpost](../../modules/signpost/README.md) registry collects them once at boot. Menus and sitemaps never import the content plugin: they read the registry. ## Registering types A plugin implements `pact.HasMenuItemTypes` and returns one `pact.MenuItemType` per type. This example registers a post type and a list of every post: ```go src=modules/signpost/example_test.go#BlogPlugin.MenuItemTypes // MenuItemTypes registers acme-post, which points at one post, and // acme-all-posts, which lists every post. The list type names acme-post in // PatternFrom, so a host override of the post URL reaches its items too. func (p BlogPlugin) MenuItemTypes() []pact.MenuItemType { return []pact.MenuItemType{{ Key: "acme-post", Label: "acme.blog::lang.menuitem.post", DefaultPattern: "/blog/:slug", Placeholders: []string{":slug"}, References: p.postReferences, Resolve: p.resolvePost, }, { Key: "acme-all-posts", Label: "acme.blog::lang.menuitem.all_posts", DefaultPattern: "/blog/:slug", PatternFrom: "acme-post", Nesting: true, DynamicItems: true, Resolve: p.resolveAllPosts, }} } ``` The fields: - `Key` is unique across all plugins; a key two plugins register stops the start-up with an error that names the key and both plugin IDs. Keep WinterCMS's keys when you port a plugin, so stored menu entries keep working. - `Label` is a translation key or text, shown in the type dropdown. - `DefaultPattern` is the type's URL pattern, required on every type and starting with `/`. The placeholders a pattern understands are listed in `Placeholders`, with WinterCMS's names (`:slug`, `:id`, `:category`, `:year`, `:month`, `:day`), so patterns stored by a PHP site still fill. - `PatternFrom` is set on a list type, whose items are records of another type: it names that record type (see [URL patterns](#url-patterns)). It must name another registered type that does not set `PatternFrom` itself. - `Nesting` reports that an item may expand into its sub-items, `DynamicItems` that its children are generated from records. - `References`, when set, returns the records an item can point at as a `pact.MenuItemReference` tree (value, label, nested records). A type without references, such as "all posts", leaves it nil. - `Resolve` is required. It receives a `pact.MenuItemResolve` and answers a `pact.ResolvedMenuItem`. A type that needs the database or another service closes over the plugin's `backpack.App` (stored in `Register`), because types are collected before any plugin boots. ## Resolving an item `Resolve` receives the item's type, its stored reference, the URL pattern already layered by the registry, whether to expand nested items, and the locale whose URLs carry no prefix. It answers: - `Title` and `URL`. A pure list type leaves `URL` empty and returns the records it lists in `Items`. - `MTime`, the record's last change, used for a sitemap's `lastmod`; nil when unknown. - `Alternates`, the item's URL and title in each locale, as an ordered `[]pact.MenuItemAlternate` in the site's locale order. It is a slice, never a map, so hreflang output keeps its order from run to run. A single-locale site returns one alternate. - `Items`, the nested items of a type that expands, each resolved the same way. A list type builds its children's URLs from `pact.MenuItemResolve.Pattern`, not from its own default: that pattern is what the registry layered for the item, so the host's override of the record type reaches the children too. ```go src=modules/signpost/example_test.go#BlogPlugin.resolveAllPosts // resolveAllPosts lists every post. It builds the children's URLs from the // layered pattern it receives, never from its own default. func (p BlogPlugin) resolveAllPosts(_ context.Context, in pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) { out := &pact.ResolvedMenuItem{} for _, post := range p.posts { url := strings.ReplaceAll(in.Pattern, ":slug", post.Slug) out.Items = append(out.Items, pact.ResolvedMenuItem{ Title: post.Title, URL: url, MTime: post.UpdatedAt, Alternates: []pact.MenuItemAlternate{{Locale: "en", URL: url, Title: post.Title}}, }) } return out, nil } ``` Consumers resolve through the registry, which picks the pattern and calls the type. In an application `party.Activate` builds the registry at boot; the example calls `signpost.Activate` itself. With the host's `acme-post` override set to `/news/:slug`, the children of the list follow it: ```go src=modules/signpost/example_test.go#ExampleRegistry_Resolve app := exampleApp() blog := BlogPlugin{posts: []BlogPost{{Slug: "hello", Title: "Hello"}, {Slug: "again", Title: "Again"}}} if err := signpost.Activate(app, []BlogPlugin{blog}); err != nil { fmt.Println(err) return } reg, _ := app.Lookup[*signpost.Registry]() item, err := reg.Resolve(context.Background(), "acme-all-posts", "", "", true, "") if err != nil { fmt.Println(err) return } for _, child := range item.Items { fmt.Println(child.Title, child.URL) } // Output: // Hello /news/hello // Again /news/again ``` `signpost.Registry.Types` lists every type for a type dropdown, in plugin order and then declaration order, and `signpost.Registry.Type` looks one up. ## URL patterns Headless sites decide their own URLs, so a type's URL comes from a pattern in layers. `signpost.Registry.Pattern` returns the first that is set: 1. The item's own override, such as a menu entry's or a sitemap row's URL pattern field. 2. The host's override of the type, from `menu_items.patterns`. 3. For a list type with `PatternFrom`, the host's override of the record type it lists. 4. The type's `DefaultPattern`. An item override that is not a path is ignored: it must start with a single `/` and hold no surrounding whitespace or control characters (`signpost.ValidItemPattern`). A `javascript:` URL or a protocol-relative `//host` in a stored menu entry or sitemap row therefore falls through to the next layer instead of reaching the resolved URL. Layer 3 is why a list type declares `PatternFrom`. A host that moves posts to `/news/:slug` sets that once, for `acme-post`, and the children of "all posts" follow, because `acme-all-posts` names `acme-post` and has no override of its own. Give a list type the same `DefaultPattern` as the type it lists, so both agree without any host configuration. The host application sets its overrides in `config/menu_items.yaml`: ```yaml patterns: acme-post: /news/:slug acme-category: /:slug ``` `menu_items.patterns` (`signpost.ConfigKey`) is read once at boot. Type keys contain dashes, so the map can only be set in a YAML file, not through a `SUMMER_` environment variable. An override for a type no plugin registers, or one that does not start with `/`, stops the start-up. ## Reference dropdowns The admin form of a menu entry or a sitemap row has a type dropdown and a reference dropdown that depends on it. The controller serves the reference options with `signpost.ReferenceOptions`, which flattens the chosen type's reference tree into options with their depth and answers `Applies` false for a type without references. See [Dependent dropdowns](../backend/forms.md#dependent-dropdowns).