- pact.ContentDescriptor gains Kind and Tools with the ContentKind and ContentTool constants - pact.ContentRecord gains PublishedAt, Parents, Authors and TopHeading; pact.ContentLocale gains Images, Videos and Headings, with the value types ContentRef, ContentPerson, ContentImage, ContentVideo and ContentHeading; every field is optional - TestContentDescriptorFields proves signpost hands them through and that a type setting none of them describes as before - the runnable content example, the pact and signpost READMEs and the menu item types page document the fields
16 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:
Keyis 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.Labelis a translation key or text, shown in the type dropdown.DefaultPatternis the type's URL pattern, required on every type and starting with/. The placeholders a pattern understands are listed inPlaceholders, with WinterCMS's names (:slug,:id,:category,:year,:month,:day), so patterns stored by a PHP site still fill.PatternFromis 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 setPatternFromitself.Nestingreports that an item may expand into its sub-items,DynamicItemsthat its children are generated from records.References, when set, returns the records an item can point at as apact.MenuItemReferencetree (value, label, nested records). A type without references, such as "all posts", leaves it nil.Resolveis required. It receives apact.MenuItemResolveand answers apact.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:
TitleandURL. A pure list type leavesURLempty and returns the records it lists inItems.MTime, the record's last change, used for a sitemap'slastmod; nil when unknown.Alternates, the item's URL and title in each locale, as an ordered[]pact.MenuItemAlternatein 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:
- The item's own override, such as a menu entry's or a sitemap row's URL pattern field.
- The host's override of the type, from
menu_items.patterns. - For a list type with
PatternFrom, the host's override of the record type it lists. - 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.
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.
// MenuItemTypes registers acme-article with a content descriptor: the
// controller that edits articles, their morph type, what kind of record an
// article is, the MCP tool that edits one, 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",
Kind: pact.ContentKindArticle,
Tools: map[string]string{pact.ContentToolEdit: "acme_update_article"},
Describe: p.describe,
Match: p.match,
},
}}
}
The descriptor names:
Controller, the ID of the admin controller whose form edits the records (signpost.Registry.ContentTypeForControllerfinds the type from it), andModel, the records' morph type: the model'sMorphName, else its table name.Modelis what the record events carry (see Record events), sosignpost.Registry.ContentTypeForModelmaps an event to its type with one lookup.Describe(required): one record by ID as apact.ContentRecord, drafts included.Publishedtells whether visitors see it andLastModis its last change.Localeslists the record per locale in the site's locale order, each with itsURL(built from the pattern the function receives, exactly asResolvebuilds alternates),Title,Description,Imageand the body'sWords. A type whose host has no public page for it, such as tags on a site without tag pages, answers an emptyURL.Records(optional): the records in pages, in a stable order. It receives apact.ContentListwithAfter, the last ID of the previous page, andLimit, and answers apact.ContentPagewhoseNextis the following page'sAfter, "" on the last page.signpost.Registry.Recordsanswers an empty page for a type without it.Match(optional): the record a public path belongs to, as apact.ContentMatchResultwith its ID and the locale the path addresses. It answers nil when the path names no record visitors can see.Kind(optional): what a record of the type is, one ofpact.ContentKindArticle(a dated piece with authors),pact.ContentKindCollection(a record that lists others, such as a category),pact.ContentKindProfile(a person or an organisation) andpact.ContentKindPage(a standalone page); "" when the type does not say.Tools(optional): the MCP tools that change the type's records, keyed by operation:pact.ContentToolEdit(fields and body),pact.ContentToolHeadings(the body's headings) andpact.ContentToolImageAlt(the alternative text of the body's images). Each value is a full tool name such asacme_update_article, so a consumer that finds a problem in a record can name the tool that fixes it without importing the content plugin.
Describe and Records may also report facts that feeds, search indexers and audit tools read. All of them are optional, and a nil slice means the same as an empty one:
| Field | Meaning |
|---|---|
pact.ContentRecord.PublishedAt |
The first publication, nil when unknown or never published (LastMod stays the last change). |
pact.ContentRecord.Parents |
The records this one sits under as pact.ContentRef values (a menu item type key and a record ID), in order of preference: the first is the default primary parent. |
pact.ContentRecord.Authors |
The people who wrote the record, in byline order, as pact.ContentPerson values: a Name and a Ref to the person's own record, whose Type is "" when the person has none. |
pact.ContentRecord.TopHeading |
The highest heading level the body may use: 2 when the page renders the title as its only level-1 heading, 0 when the type sets no rule. |
pact.ContentLocale.Images |
The record's images in that locale (the featured image first, then the body's in document order) as pact.ContentImage values: URL, Alt, Decorative (an image without content, whose empty Alt is intended), Width and Height in pixels (0 when unknown). |
pact.ContentLocale.Videos |
The videos the body embeds, as pact.ContentVideo values: a Provider such as youtube and the video's ID there. |
pact.ContentLocale.Headings |
The body's heading outline in document order, as pact.ContentHeading values with a Level (1 to 6) and the Text. |
The registry hands these values through as the type answers them; a type that sets none of them describes, lists and matches exactly as before.
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:
// 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:
app := exampleApp() // its menu_items.yaml overrides acme-post of BlogPlugin
news := NewsPlugin{articles: []Article{{
ID: "7", Slug: "launch", Title: "Launch", Published: true,
PublishedAt: time.Date(2026, 10, 1, 9, 0, 0, 0, time.UTC),
Author: "Ada", Headings: []string{"Why", "How"},
}}}
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)
// The descriptor's optional facts reach every consumer as given.
fmt.Println(typ.Content.Kind, typ.Content.Tools[pact.ContentToolEdit])
fmt.Println(rec.PublishedAt.Format(time.DateOnly), rec.Authors[0].Name, rec.TopHeading, len(rec.Locales[0].Headings))
// 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
// article acme_update_article
// 2026-10-01 Ada 2 2
// 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.