Files
summercms/docs/plugins/menu-item-types.md
Jakub Zych 964f1a7f11 fix(17): CR-01 ignore an item URL pattern override that is not a path
signpost.Registry.Pattern now takes an item override only when it starts
with a single "/" and holds no surrounding whitespace or control
characters (signpost.ValidItemPattern); a javascript: URL or a
protocol-relative //host falls through to the next layer.
2026-10-12 21:25:30 +02:00

7.6 KiB

title, description, section, order
title description section order
Menu item types Register typed menu item types from a content plugin, so menus and sitemaps link to its records through URL patterns the host can override. plugins 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 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:

// 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). 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.

// 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:

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:

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.