- ContentType, ContentTypeForController, Records and MatchPath on the registry; the pure MatchPattern for whole-segment placeholder matching - two types declaring the same Content.Model or Controller stop the boot - TestContent* cover lookups, optional Records, boot errors, MatchPattern and MatchPath precedence (first exact, else first inexact) - docs: Content descriptors in menu-item-types.md, signpost README
13 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, 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.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.
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}}}
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.