feat(32-02): signpost content lookups, Records and MatchPath
- 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
This commit is contained in:
@@ -125,6 +125,86 @@ patterns:
|
||||
|
||||
`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).
|
||||
|
||||
Reference in New Issue
Block a user