--- 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. ## Content descriptors Menus point at single records. Consumers such as sitemaps, search indexers and metadata editors work on every record of every content type, and they find the records through the same registry. A record type sets `Content` to a `pact.ContentDescriptor`; a type without one takes no part and behaves exactly as before. ```go src=modules/signpost/example_content_test.go#NewsPlugin.MenuItemTypes // MenuItemTypes registers acme-article with a content descriptor: the // controller that edits articles, their morph type, Describe and Match. func (p NewsPlugin) MenuItemTypes() []pact.MenuItemType { return []pact.MenuItemType{{ Key: "acme-article", Label: "acme.news::lang.menuitem.article", DefaultPattern: "/news/:slug", Placeholders: []string{":slug", ":id"}, Resolve: p.resolve, Content: &pact.ContentDescriptor{ Controller: "acme.news.articles", Model: "acme_news_articles", Describe: p.describe, Match: p.match, }, }} } ``` The descriptor names: - `Controller`, the ID of the admin controller whose form edits the records (`signpost.Registry.ContentTypeForController` finds the type from it), and `Model`, the records' morph type: the model's `MorphName`, else its table name. `Model` is what the record events carry (see [Record events](extending.md#record-events)), so `signpost.Registry.ContentTypeForModel` maps an event to its type with one lookup. - `Describe` (required): one record by ID as a `pact.ContentRecord`, drafts included. `Published` tells whether visitors see it and `LastMod` is its last change. `Locales` lists the record per locale in the site's locale order, each with its `URL` (built from the pattern the function receives, exactly as `Resolve` builds alternates), `Title`, `Description`, `Image` and the body's `Words`. A type whose host has no public page for it, such as tags on a site without tag pages, answers an empty `URL`. - `Records` (optional): the records in pages, in a stable order. It receives a `pact.ContentList` with `After`, the last ID of the previous page, and `Limit`, and answers a `pact.ContentPage` whose `Next` is the following page's `After`, "" on the last page. `signpost.Registry.Records` answers an empty page for a type without it. - `Match` (optional): the record a public path belongs to, as a `pact.ContentMatchResult` with its ID and the locale the path addresses. It answers nil when the path names no record visitors can see. A content type reads a path's placeholder values with `signpost.MatchPattern`, which matches whole segments only: a literal segment must be equal, the segment count must be equal and a placeholder never matches an empty segment. Every value must then fit the record. When the path names a record but a segment does not fit it (another category, a stale locale prefix, the slug of another locale), the result's `Exact` is false, so the caller can redirect to the record's own URL instead of answering a duplicate page: ```go src=modules/signpost/example_content_test.go#NewsPlugin.match // match reads the path's values with signpost.MatchPattern and checks every // one against the record: a slug finds the article, and an :id that names // another article makes the match inexact. Drafts never match. func (p NewsPlugin) match(_ context.Context, in pact.ContentMatch) (*pact.ContentMatchResult, error) { values, ok := signpost.MatchPattern(in.Pattern, in.Path) if !ok { return nil, nil } for _, a := range p.articles { if a.Slug != values["slug"] || !a.Published { continue } id, hasID := values["id"] return &pact.ContentMatchResult{ID: a.ID, Locale: "en", Exact: !hasID || id == a.ID}, nil } return nil, nil } ``` The registry layers the pattern for every call, like `Resolve`. `signpost.Registry.Describe` and `signpost.Registry.Records` describe one type's records; `signpost.Registry.MatchPath` asks every type with `Match` in registration order and returns the first exact match, else the first inexact one: ```go src=modules/signpost/example_content_test.go#ExampleRegistry_MatchPath app := exampleApp() // its menu_items.yaml overrides acme-post of BlogPlugin news := NewsPlugin{articles: []Article{{ID: "7", Slug: "launch", Title: "Launch", Published: true}}} plugins := []interface{ ID() string }{BlogPlugin{}, news} if err := signpost.Activate(app, plugins); err != nil { fmt.Println(err) return } reg, _ := app.Lookup[*signpost.Registry]() // A record event names the model; the registry finds its type. typ, _ := reg.ContentTypeForModel("acme_news_articles") rec, _ := reg.Describe(context.Background(), typ.Key, "7", "") fmt.Println(typ.Key, rec.Locales[0].URL) // A visitor's path maps back to the record. key, res, _ := reg.MatchPath(context.Background(), "/news/launch", "") fmt.Println(key, res.ID, res.Exact) // Output: // acme-article /news/launch // acme-article 7 true ``` The descriptor functions read through `cabana.TxFromContext(ctx)` when the context carries a write transaction, so code running inside an admin save (a form extension's `Save`) sees what the save has just written. A path's values come from visitors: bind them as query parameters, never concatenate them into SQL. A descriptor without `Model` or `Describe`, and two types declaring the same `Model` or the same `Controller`, stop the boot. ## 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).