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).
|
||||
|
||||
@@ -25,7 +25,7 @@ Use cases:
|
||||
- Boot-time collection in plugin order, then declaration order. Every error is prefixed `signpost:` and fails the boot: an empty key, a key two plugins register (the message names the key and both plugin IDs), a nil `Resolve`, a `DefaultPattern` that is empty or does not start with `/`, a `PatternFrom` that names an unknown type, the type itself or a type that sets `PatternFrom` too, and a host override for an unknown type or one that does not start with `/`.
|
||||
- URL patterns in layers (`signpost.Registry.Pattern`): the item's own override, else the host override of the type, else, for a list type that sets `PatternFrom`, the host override of the record type it lists, else the type's `DefaultPattern`. A host that changes the post URL once therefore also changes the children of every list of posts, unless the list type itself is overridden. An item override is taken only when it is a path (`signpost.ValidItemPattern`): it starts with a single `/` and has no surrounding whitespace or control characters. Any other override, such as a `javascript:` URL or a protocol-relative `//host`, is ignored and the next layer applies.
|
||||
- `signpost.Registry.Resolve` hands the layered pattern to the type's `Resolve` in `pact.MenuItemResolve.Pattern`. The result, `pact.ResolvedMenuItem`, carries the title, URL, modification time, nested items, and the alternates per locale as an ordered `[]pact.MenuItemAlternate`, never a map.
|
||||
- Content descriptors: a record type that sets `pact.MenuItemType.Content` describes its records to consumers that work on every content type at once. `signpost.Registry.ContentTypes` lists those types, `signpost.Registry.ContentTypeForModel` maps the morph type a record event carries (`pact.RecordSaved.Model`) to its type, and `signpost.Registry.Describe` describes one record with the layered pattern. A descriptor without `Model` or `Describe` fails the boot.
|
||||
- Content descriptors: a record type that sets `pact.MenuItemType.Content` describes its records to consumers that work on every content type at once. `signpost.Registry.ContentTypes` lists those types, `signpost.Registry.ContentType`, `signpost.Registry.ContentTypeForController` and `signpost.Registry.ContentTypeForModel` find one by key, admin controller ID or the morph type a record event carries (`pact.RecordSaved.Model`), `signpost.Registry.Describe` describes one record and `signpost.Registry.Records` pages through them with the layered pattern. `signpost.Registry.MatchPath` maps a public path to its record: the first exact match in registration order, else the first inexact one (a path that names the record with a segment that does not fit it). `signpost.MatchPattern` is the whole-segment matcher content types read placeholder values with. A descriptor without `Model` or `Describe`, and two types declaring the same `Model` or `Controller`, fail the boot.
|
||||
- `signpost.ReferenceOptions` flattens a type's reference tree depth first into dropdown options with a `Depth`, and answers `Applies` false for a type that takes no reference or that no plugin registers.
|
||||
|
||||
## Usage
|
||||
@@ -147,8 +147,13 @@ app.Events.Listen("acme.audit", func(ctx context.Context, ev *pact.RecordSaved)
|
||||
| `signpost.ValidItemPattern` | Whether a pattern may serve as an item override: a path starting with a single `/`, without surrounding whitespace or control characters. Plugins use it to refuse such a pattern on save. |
|
||||
| `signpost.Registry.Resolve` | Resolves one item through its type with the layered pattern; an unknown type is an error. |
|
||||
| `signpost.Registry.ContentTypes` | The types that carry a content descriptor, in registration order. |
|
||||
| `signpost.Registry.ContentType` | The type registered under a key when it carries a content descriptor. |
|
||||
| `signpost.Registry.ContentTypeForController` | The content type whose descriptor names an admin controller ID. |
|
||||
| `signpost.Registry.ContentTypeForModel` | The content type whose descriptor declares a morph type, as record events carry it. |
|
||||
| `signpost.Registry.Describe` | Describes one record of a content type through its descriptor with the layered pattern; an unknown type or one without a descriptor is an error, an unknown record answers nil. |
|
||||
| `signpost.Registry.Records` | One page of a content type's records after a given ID; an empty page for a type without `Records`. |
|
||||
| `signpost.Registry.MatchPath` | The content type and record a public path belongs to: the first exact `Match` in registration order, else the first inexact one, else none. |
|
||||
| `signpost.MatchPattern` | Matches a path against a URL pattern by whole segments and returns the placeholder values; a literal segment, the segment count and a non-empty value must all fit. |
|
||||
| `signpost.ReferenceOptions` | The dependent dropdown options of a type's references, flattened with their depth; `Applies` false for a type without references. |
|
||||
| `signpost.ConfigKey` | `menu_items.patterns`, the host configuration key of the pattern overrides. |
|
||||
|
||||
|
||||
@@ -8,9 +8,10 @@ import (
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
// checkContent validates a type's content descriptor: Model and Describe are
|
||||
// required when Content is set.
|
||||
func checkContent(owner string, t pact.MenuItemType) error {
|
||||
// addContent validates a type's content descriptor before the type is
|
||||
// added: Model and Describe are required when Content is set, and no two
|
||||
// types may describe the same Model or name the same non-empty Controller.
|
||||
func (r *Registry) addContent(owner string, t pact.MenuItemType) error {
|
||||
c := t.Content
|
||||
if c == nil {
|
||||
return nil
|
||||
@@ -21,9 +22,28 @@ func checkContent(owner string, t pact.MenuItemType) error {
|
||||
if c.Describe == nil {
|
||||
return fmt.Errorf("signpost: menu item type %q of plugin %s has a content descriptor without Describe", t.Key, owner)
|
||||
}
|
||||
if first, dup := r.byModel[c.Model]; dup {
|
||||
return fmt.Errorf("signpost: menu item type %q of plugin %s describes model %q, which %q of plugin %s already describes", t.Key, owner, c.Model, first, r.owners[first])
|
||||
}
|
||||
if c.Controller != "" {
|
||||
if first, dup := r.byController[c.Controller]; dup {
|
||||
return fmt.Errorf("signpost: menu item type %q of plugin %s names controller %q, which %q of plugin %s already names", t.Key, owner, c.Controller, first, r.owners[first])
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// indexContent records a type's descriptor once the type is added.
|
||||
func (r *Registry) indexContent(t pact.MenuItemType) {
|
||||
if t.Content == nil {
|
||||
return
|
||||
}
|
||||
r.byModel[t.Content.Model] = t.Key
|
||||
if t.Content.Controller != "" {
|
||||
r.byController[t.Content.Controller] = t.Key
|
||||
}
|
||||
}
|
||||
|
||||
// ContentTypes returns the types that carry a content descriptor
|
||||
// (pact.MenuItemType.Content), in registration order. The slice is a copy.
|
||||
func (r *Registry) ContentTypes() []pact.MenuItemType {
|
||||
@@ -39,18 +59,40 @@ func (r *Registry) ContentTypes() []pact.MenuItemType {
|
||||
return out
|
||||
}
|
||||
|
||||
// ContentType returns the type registered under key when it carries a
|
||||
// content descriptor.
|
||||
func (r *Registry) ContentType(key string) (pact.MenuItemType, bool) {
|
||||
t, ok := r.Type(key)
|
||||
if !ok || t.Content == nil {
|
||||
return pact.MenuItemType{}, false
|
||||
}
|
||||
return t, true
|
||||
}
|
||||
|
||||
// ContentTypeForController returns the content type whose descriptor names
|
||||
// the admin controller id, the controller whose form edits its records.
|
||||
func (r *Registry) ContentTypeForController(id string) (pact.MenuItemType, bool) {
|
||||
if r == nil || id == "" {
|
||||
return pact.MenuItemType{}, false
|
||||
}
|
||||
key, ok := r.byController[id]
|
||||
if !ok {
|
||||
return pact.MenuItemType{}, false
|
||||
}
|
||||
return r.Type(key)
|
||||
}
|
||||
|
||||
// ContentTypeForModel returns the content type whose descriptor declares
|
||||
// model, the morph type record events carry (pact.RecordSaved.Model).
|
||||
func (r *Registry) ContentTypeForModel(model string) (pact.MenuItemType, bool) {
|
||||
if r == nil || model == "" {
|
||||
return pact.MenuItemType{}, false
|
||||
}
|
||||
for _, t := range r.types {
|
||||
if t.Content != nil && t.Content.Model == model {
|
||||
return t, true
|
||||
}
|
||||
key, ok := r.byModel[model]
|
||||
if !ok {
|
||||
return pact.MenuItemType{}, false
|
||||
}
|
||||
return pact.MenuItemType{}, false
|
||||
return r.Type(key)
|
||||
}
|
||||
|
||||
// Describe describes one record of the content type key through its
|
||||
@@ -72,6 +114,108 @@ func (r *Registry) Describe(ctx context.Context, key, id, unprefixedLocale strin
|
||||
})
|
||||
}
|
||||
|
||||
// Records pages through the records of the content type key: after is the
|
||||
// ID of the last record of the previous page ("" for the first), limit the
|
||||
// page size the caller wants. The descriptor gets the layered pattern
|
||||
// (Pattern(key, "")). A content type without Records answers an empty
|
||||
// page; an unknown key or a type without a content descriptor is an error.
|
||||
func (r *Registry) Records(ctx context.Context, key, after string, limit int, unprefixedLocale string) (pact.ContentPage, error) {
|
||||
t, err := r.contentType(key)
|
||||
if err != nil {
|
||||
return pact.ContentPage{}, err
|
||||
}
|
||||
if t.Content.Records == nil {
|
||||
return pact.ContentPage{Records: []pact.ContentRecord{}}, nil
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
return t.Content.Records(ctx, pact.ContentList{
|
||||
Pattern: r.Pattern(key, ""),
|
||||
After: after,
|
||||
Limit: limit,
|
||||
UnprefixedLocale: unprefixedLocale,
|
||||
})
|
||||
}
|
||||
|
||||
// MatchPath finds the record a public path belongs to: it asks every
|
||||
// content type with Match, in registration order, with the type's layered
|
||||
// pattern. The first exact match wins; without one, the first non-exact
|
||||
// match (a record the path names with a segment that does not fit it) is
|
||||
// returned; without any, key is "" and res nil. A Match error stops the
|
||||
// search and is returned.
|
||||
func (r *Registry) MatchPath(ctx context.Context, path, unprefixedLocale string) (key string, res *pact.ContentMatchResult, err error) {
|
||||
if r == nil {
|
||||
return "", nil, nil
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
var (
|
||||
fallbackKey string
|
||||
fallback *pact.ContentMatchResult
|
||||
)
|
||||
for _, t := range r.types {
|
||||
if t.Content == nil || t.Content.Match == nil {
|
||||
continue
|
||||
}
|
||||
got, err := t.Content.Match(ctx, pact.ContentMatch{
|
||||
Path: path,
|
||||
Pattern: r.Pattern(t.Key, ""),
|
||||
UnprefixedLocale: unprefixedLocale,
|
||||
})
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("signpost: match %q in menu item type %q: %w", path, t.Key, err)
|
||||
}
|
||||
if got == nil {
|
||||
continue
|
||||
}
|
||||
if got.Exact {
|
||||
return t.Key, got, nil
|
||||
}
|
||||
if fallback == nil {
|
||||
fallbackKey, fallback = t.Key, got
|
||||
}
|
||||
}
|
||||
return fallbackKey, fallback, nil
|
||||
}
|
||||
|
||||
// MatchPattern matches a path against a URL pattern such as
|
||||
// "/blog/:category/:slug" and returns the placeholder values. A placeholder
|
||||
// is a whole segment starting with ":"; every other segment must equal the
|
||||
// path's, the path must have exactly as many segments, a placeholder never
|
||||
// matches an empty segment, and a placeholder used twice must match the
|
||||
// same value. Segments are compared as given: the caller decodes the path.
|
||||
// Content types use it in Match to read a path's values.
|
||||
func MatchPattern(pattern, path string) (map[string]string, bool) {
|
||||
if !strings.HasPrefix(pattern, "/") || !strings.HasPrefix(path, "/") {
|
||||
return nil, false
|
||||
}
|
||||
want := strings.Split(pattern[1:], "/")
|
||||
got := strings.Split(path[1:], "/")
|
||||
if len(want) != len(got) {
|
||||
return nil, false
|
||||
}
|
||||
values := map[string]string{}
|
||||
for i, seg := range want {
|
||||
if len(seg) > 1 && seg[0] == ':' {
|
||||
name := seg[1:]
|
||||
if got[i] == "" {
|
||||
return nil, false
|
||||
}
|
||||
if prev, seen := values[name]; seen && prev != got[i] {
|
||||
return nil, false
|
||||
}
|
||||
values[name] = got[i]
|
||||
continue
|
||||
}
|
||||
if seg != got[i] {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
return values, true
|
||||
}
|
||||
|
||||
// contentType is the type key with its content descriptor.
|
||||
func (r *Registry) contentType(key string) (pact.MenuItemType, error) {
|
||||
t, ok := r.Type(key)
|
||||
|
||||
265
modules/signpost/content_test.go
Normal file
265
modules/signpost/content_test.go
Normal file
@@ -0,0 +1,265 @@
|
||||
package signpost_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/signpost"
|
||||
)
|
||||
|
||||
// contentCalls records what the descriptor funcs of a test type received.
|
||||
type contentCalls struct {
|
||||
describe []pact.ContentDescribe
|
||||
list []pact.ContentList
|
||||
match []pact.ContentMatch
|
||||
}
|
||||
|
||||
// contentType is a record type whose descriptor answers match for the paths
|
||||
// it names, with Exact as given.
|
||||
func contentType(key, pattern, model, controller string, calls *contentCalls, match map[string]bool) pact.MenuItemType {
|
||||
c := &pact.ContentDescriptor{
|
||||
Controller: controller,
|
||||
Model: model,
|
||||
Describe: func(_ context.Context, in pact.ContentDescribe) (*pact.ContentRecord, error) {
|
||||
calls.describe = append(calls.describe, in)
|
||||
if in.ID == "missing" {
|
||||
return nil, nil
|
||||
}
|
||||
return &pact.ContentRecord{ID: in.ID, Published: true, Locales: []pact.ContentLocale{{Locale: "en", URL: strings.ReplaceAll(in.Pattern, ":slug", in.ID)}}}, nil
|
||||
},
|
||||
Records: func(_ context.Context, in pact.ContentList) (pact.ContentPage, error) {
|
||||
calls.list = append(calls.list, in)
|
||||
return pact.ContentPage{Records: []pact.ContentRecord{{ID: "1"}}, Next: "1"}, nil
|
||||
},
|
||||
}
|
||||
if match != nil {
|
||||
c.Match = func(_ context.Context, in pact.ContentMatch) (*pact.ContentMatchResult, error) {
|
||||
calls.match = append(calls.match, in)
|
||||
if in.Path == "/boom" {
|
||||
return nil, errors.New("database down")
|
||||
}
|
||||
exact, ok := match[in.Path]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
return &pact.ContentMatchResult{ID: key + "-record", Locale: "en", Exact: exact}, nil
|
||||
}
|
||||
}
|
||||
return pact.MenuItemType{Key: key, DefaultPattern: pattern, Resolve: echo, Content: c}
|
||||
}
|
||||
|
||||
func contentRegistry(t *testing.T, host string, types ...pact.MenuItemType) *signpost.Registry {
|
||||
t.Helper()
|
||||
app := smokeApp(t, host)
|
||||
if err := signpost.Activate(app, []typesPlugin{{id: "acme.blog", types: types}}); err != nil {
|
||||
t.Fatalf("Activate: %v", err)
|
||||
}
|
||||
reg, ok := app.Lookup[*signpost.Registry]()
|
||||
if !ok {
|
||||
t.Fatal("registry not published")
|
||||
}
|
||||
return reg
|
||||
}
|
||||
|
||||
func keys(types []pact.MenuItemType) []string {
|
||||
out := []string{}
|
||||
for _, t := range types {
|
||||
out = append(out, t.Key)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestContentLookups: types with a content descriptor are listed in
|
||||
// registration order and found by key, controller and model; a type without
|
||||
// one is absent and resolves exactly as before; Describe and Records hand
|
||||
// the descriptor the layered pattern.
|
||||
func TestContentLookups(t *testing.T) {
|
||||
posts, tags := &contentCalls{}, &contentCalls{}
|
||||
reg := contentRegistry(t, "patterns:\n acme-post: /news/:slug\n",
|
||||
pact.MenuItemType{Key: "acme-page", DefaultPattern: "/:slug", Resolve: echo},
|
||||
contentType("acme-post", "/blog/:slug", "acme_posts", "acme.blog.posts", posts, nil),
|
||||
contentType("acme-tag", "/blog/tag/:slug", "acme_tags", "", tags, nil),
|
||||
)
|
||||
if got := keys(reg.ContentTypes()); !reflect.DeepEqual(got, []string{"acme-post", "acme-tag"}) {
|
||||
t.Fatalf("ContentTypes = %v", got)
|
||||
}
|
||||
if typ, ok := reg.ContentType("acme-post"); !ok || typ.Key != "acme-post" {
|
||||
t.Fatalf("ContentType(acme-post) = %v %v", typ.Key, ok)
|
||||
}
|
||||
if _, ok := reg.ContentType("acme-page"); ok {
|
||||
t.Fatal("a type without Content is a content type")
|
||||
}
|
||||
if _, ok := reg.ContentType("acme-missing"); ok {
|
||||
t.Fatal("an unknown key is a content type")
|
||||
}
|
||||
if typ, ok := reg.ContentTypeForController("acme.blog.posts"); !ok || typ.Key != "acme-post" {
|
||||
t.Fatalf("ContentTypeForController = %v %v", typ.Key, ok)
|
||||
}
|
||||
if _, ok := reg.ContentTypeForController(""); ok {
|
||||
t.Fatal("the empty controller ID names a type")
|
||||
}
|
||||
if typ, ok := reg.ContentTypeForModel("acme_tags"); !ok || typ.Key != "acme-tag" {
|
||||
t.Fatalf("ContentTypeForModel = %v %v", typ.Key, ok)
|
||||
}
|
||||
if _, ok := reg.ContentTypeForModel("acme_pages"); ok {
|
||||
t.Fatal("an unknown model names a type")
|
||||
}
|
||||
|
||||
item, err := reg.Resolve(context.Background(), "acme-page", "", "", false, "")
|
||||
if err != nil || item.URL != "/:slug" {
|
||||
t.Fatalf("a type without Content resolves to %+v, %v", item, err)
|
||||
}
|
||||
|
||||
rec, err := reg.Describe(context.Background(), "acme-post", "hello", "pl")
|
||||
if err != nil || rec == nil || rec.Locales[0].URL != "/news/hello" {
|
||||
t.Fatalf("Describe = %+v, %v", rec, err)
|
||||
}
|
||||
if want := (pact.ContentDescribe{ID: "hello", Pattern: "/news/:slug", UnprefixedLocale: "pl"}); !reflect.DeepEqual(posts.describe, []pact.ContentDescribe{want}) {
|
||||
t.Fatalf("Describe received %+v", posts.describe)
|
||||
}
|
||||
if rec, err := reg.Describe(context.Background(), "acme-post", "missing", ""); err != nil || rec != nil {
|
||||
t.Fatalf("Describe of an unknown record = %+v, %v", rec, err)
|
||||
}
|
||||
for _, key := range []string{"acme-page", "acme-missing"} {
|
||||
if _, err := reg.Describe(context.Background(), key, "1", ""); err == nil || !strings.HasPrefix(err.Error(), "signpost: ") {
|
||||
t.Fatalf("Describe(%s) error = %v", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
page, err := reg.Records(context.Background(), "acme-tag", "7", 50, "en")
|
||||
if err != nil || len(page.Records) != 1 || page.Next != "1" {
|
||||
t.Fatalf("Records = %+v, %v", page, err)
|
||||
}
|
||||
if want := (pact.ContentList{Pattern: "/blog/tag/:slug", After: "7", Limit: 50, UnprefixedLocale: "en"}); !reflect.DeepEqual(tags.list, []pact.ContentList{want}) {
|
||||
t.Fatalf("Records received %+v", tags.list)
|
||||
}
|
||||
if _, err := reg.Records(context.Background(), "acme-page", "", 10, ""); err == nil {
|
||||
t.Fatal("Records of a type without Content succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
// TestContentRecordsOptional: a content type without Records answers an
|
||||
// empty page.
|
||||
func TestContentRecordsOptional(t *testing.T) {
|
||||
typ := contentType("acme-post", "/blog/:slug", "acme_posts", "", &contentCalls{}, nil)
|
||||
typ.Content.Records = nil
|
||||
reg := contentRegistry(t, "", typ)
|
||||
page, err := reg.Records(context.Background(), "acme-post", "", 10, "")
|
||||
if err != nil || len(page.Records) != 0 || page.Next != "" {
|
||||
t.Fatalf("Records without a func = %+v, %v", page, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestContentBootErrors: a descriptor without Model or Describe, and two
|
||||
// types declaring the same Model or Controller, stop the boot naming the
|
||||
// type and the plugin.
|
||||
func TestContentBootErrors(t *testing.T) {
|
||||
calls := &contentCalls{}
|
||||
noModel := contentType("acme-post", "/blog/:slug", "", "", calls, nil)
|
||||
noDescribe := contentType("acme-post", "/blog/:slug", "acme_posts", "", calls, nil)
|
||||
noDescribe.Content.Describe = nil
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
types []pact.MenuItemType
|
||||
want []string
|
||||
}{
|
||||
{"no Model", []pact.MenuItemType{noModel}, []string{`"acme-post"`, "acme.blog", "Model"}},
|
||||
{"no Describe", []pact.MenuItemType{noDescribe}, []string{`"acme-post"`, "acme.blog", "Describe"}},
|
||||
{"same Model", []pact.MenuItemType{
|
||||
contentType("acme-post", "/a/:slug", "acme_posts", "", calls, nil),
|
||||
contentType("acme-article", "/b/:slug", "acme_posts", "", calls, nil),
|
||||
}, []string{`"acme-article"`, `"acme-post"`, `"acme_posts"`}},
|
||||
{"same Controller", []pact.MenuItemType{
|
||||
contentType("acme-post", "/a/:slug", "acme_posts", "acme.blog.posts", calls, nil),
|
||||
contentType("acme-article", "/b/:slug", "acme_articles", "acme.blog.posts", calls, nil),
|
||||
}, []string{`"acme-article"`, `"acme-post"`, `"acme.blog.posts"`}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := signpost.Activate(smokeApp(t, ""), []typesPlugin{{id: "acme.blog", types: tc.types}})
|
||||
if err == nil {
|
||||
t.Fatal("Activate accepted the descriptor")
|
||||
}
|
||||
if !strings.HasPrefix(err.Error(), "signpost: ") {
|
||||
t.Fatalf("error = %q, want the signpost prefix", err)
|
||||
}
|
||||
for _, part := range tc.want {
|
||||
if !strings.Contains(err.Error(), part) {
|
||||
t.Fatalf("error = %q, want %q", err, part)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
// Two types without a controller ID do not clash.
|
||||
contentRegistry(t, "",
|
||||
contentType("acme-post", "/a/:slug", "acme_posts", "", calls, nil),
|
||||
contentType("acme-tag", "/b/:slug", "acme_tags", "", calls, nil),
|
||||
)
|
||||
}
|
||||
|
||||
// TestContentMatchPattern: whole-segment placeholders read their values; a
|
||||
// literal segment, the segment count and an empty value must all match.
|
||||
func TestContentMatchPattern(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
pattern, path string
|
||||
want map[string]string
|
||||
ok bool
|
||||
}{
|
||||
{"/blog/:slug", "/blog/hello", map[string]string{"slug": "hello"}, true},
|
||||
{"/:category/:slug", "/news/hello", map[string]string{"category": "news", "slug": "hello"}, true},
|
||||
{"/blog/:year/:month/:slug", "/blog/2026/10/hello", map[string]string{"year": "2026", "month": "10", "slug": "hello"}, true},
|
||||
{"/", "/", map[string]string{}, true},
|
||||
{"/blog", "/blog", map[string]string{}, true},
|
||||
{"/blog/:slug", "/blog/hello/extra", nil, false},
|
||||
{"/blog/:slug", "/blog", nil, false},
|
||||
{"/blog/:slug", "/blog/", nil, false},
|
||||
{"/blog/:slug", "/news/hello", nil, false},
|
||||
{"/:category/:slug", "//hello", nil, false},
|
||||
{"/blog/:slug", "blog/hello", nil, false},
|
||||
{"/:slug/:slug", "/a/b", nil, false},
|
||||
{"/:slug/:slug", "/a/a", map[string]string{"slug": "a"}, true},
|
||||
} {
|
||||
got, ok := signpost.MatchPattern(tc.pattern, tc.path)
|
||||
if ok != tc.ok || (tc.ok && !reflect.DeepEqual(got, tc.want)) {
|
||||
t.Fatalf("MatchPattern(%q, %q) = %v %v, want %v %v", tc.pattern, tc.path, got, ok, tc.want, tc.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestContentMatchPath: MatchPath asks the content types with Match in
|
||||
// registration order with their layered pattern; the first exact match
|
||||
// wins, else the first non-exact one, else none; a Match error is returned.
|
||||
func TestContentMatchPath(t *testing.T) {
|
||||
first, second := &contentCalls{}, &contentCalls{}
|
||||
reg := contentRegistry(t, "patterns:\n acme-page: /p/:slug\n",
|
||||
contentType("acme-post", "/blog/:slug", "acme_posts", "", first, map[string]bool{"/shared": false, "/post-only": false, "/both-exact": true}),
|
||||
contentType("acme-plain", "/plain/:slug", "acme_plain", "", &contentCalls{}, nil),
|
||||
contentType("acme-page", "/:slug", "acme_pages", "", second, map[string]bool{"/shared": true, "/both-exact": true}),
|
||||
)
|
||||
ctx := context.Background()
|
||||
key, res, err := reg.MatchPath(ctx, "/shared", "pl")
|
||||
if err != nil || key != "acme-page" || res == nil || !res.Exact || res.ID != "acme-page-record" {
|
||||
t.Fatalf("exact match = %q %+v %v, want acme-page exact", key, res, err)
|
||||
}
|
||||
if len(first.match) != 1 || first.match[0] != (pact.ContentMatch{Path: "/shared", Pattern: "/blog/:slug", UnprefixedLocale: "pl"}) {
|
||||
t.Fatalf("first type received %+v", first.match)
|
||||
}
|
||||
if len(second.match) != 1 || second.match[0].Pattern != "/p/:slug" {
|
||||
t.Fatalf("second type received %+v, want the host pattern", second.match)
|
||||
}
|
||||
if key, res, err := reg.MatchPath(ctx, "/both-exact", ""); err != nil || key != "acme-post" || !res.Exact {
|
||||
t.Fatalf("two exact matches = %q %+v %v, want the first registered", key, res, err)
|
||||
}
|
||||
if key, res, err := reg.MatchPath(ctx, "/post-only", ""); err != nil || key != "acme-post" || res == nil || res.Exact {
|
||||
t.Fatalf("non-exact match = %q %+v %v", key, res, err)
|
||||
}
|
||||
if key, res, err := reg.MatchPath(ctx, "/nothing", ""); err != nil || key != "" || res != nil {
|
||||
t.Fatalf("no match = %q %+v %v", key, res, err)
|
||||
}
|
||||
if _, _, err := reg.MatchPath(ctx, "/boom", ""); err == nil || !strings.HasPrefix(err.Error(), "signpost: ") || !strings.Contains(err.Error(), "acme-post") {
|
||||
t.Fatalf("Match error = %v", err)
|
||||
}
|
||||
}
|
||||
104
modules/signpost/example_content_test.go
Normal file
104
modules/signpost/example_content_test.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package signpost_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/signpost"
|
||||
)
|
||||
|
||||
// Article is the example's stand-in for a content plugin's record.
|
||||
type Article struct {
|
||||
ID string
|
||||
Slug string
|
||||
Title string
|
||||
Published bool
|
||||
}
|
||||
|
||||
// NewsPlugin is an example content plugin whose record type describes its
|
||||
// articles to consumers such as sitemaps.
|
||||
type NewsPlugin struct{ articles []Article }
|
||||
|
||||
func (NewsPlugin) ID() string { return "acme.news" }
|
||||
|
||||
// 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,
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func (p NewsPlugin) resolve(_ context.Context, in pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// describe answers one article per locale; the URL is built from the
|
||||
// layered pattern it receives.
|
||||
func (p NewsPlugin) describe(_ context.Context, in pact.ContentDescribe) (*pact.ContentRecord, error) {
|
||||
for _, a := range p.articles {
|
||||
if a.ID != in.ID {
|
||||
continue
|
||||
}
|
||||
url := strings.NewReplacer(":slug", a.Slug, ":id", a.ID).Replace(in.Pattern)
|
||||
return &pact.ContentRecord{
|
||||
ID: a.ID,
|
||||
Published: a.Published,
|
||||
Locales: []pact.ContentLocale{{Locale: "en", URL: url, Title: a.Title}},
|
||||
}, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
func 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
|
||||
}
|
||||
@@ -26,6 +26,10 @@ type Registry struct {
|
||||
byKey map[string]int
|
||||
owners map[string]string
|
||||
host map[string]string
|
||||
// byModel and byController map a content descriptor's Model and
|
||||
// Controller to the type key.
|
||||
byModel map[string]string
|
||||
byController map[string]string
|
||||
}
|
||||
|
||||
// Activate collects the menu item types of every plugin that implements
|
||||
@@ -36,12 +40,15 @@ type Registry struct {
|
||||
// register (the message names both plugin IDs), a nil Resolve, a
|
||||
// DefaultPattern that is empty or does not start with "/", a PatternFrom that
|
||||
// names an unknown type, the type itself or a type that sets PatternFrom, and
|
||||
// a host override for an unknown type or not starting with "/".
|
||||
// a host override for an unknown type or not starting with "/". A content
|
||||
// descriptor (pact.MenuItemType.Content) without Model or Describe, or one
|
||||
// whose Model or non-empty Controller another type already declares, fails
|
||||
// the boot too.
|
||||
func Activate[P interface{ ID() string }](app *backpack.App, plugins []P) error {
|
||||
if app == nil {
|
||||
return fmt.Errorf("signpost: app is nil")
|
||||
}
|
||||
reg := &Registry{byKey: map[string]int{}, owners: map[string]string{}, host: map[string]string{}}
|
||||
reg := &Registry{byKey: map[string]int{}, owners: map[string]string{}, host: map[string]string{}, byModel: map[string]string{}, byController: map[string]string{}}
|
||||
for _, p := range plugins {
|
||||
src, ok := any(p).(pact.HasMenuItemTypes)
|
||||
if !ok || src == nil {
|
||||
@@ -78,13 +85,14 @@ func (r *Registry) add(owner string, t pact.MenuItemType) error {
|
||||
if !strings.HasPrefix(t.DefaultPattern, "/") {
|
||||
return fmt.Errorf("signpost: menu item type %q of plugin %s needs a DefaultPattern starting with \"/\", got %q", t.Key, owner, t.DefaultPattern)
|
||||
}
|
||||
if err := checkContent(owner, t); err != nil {
|
||||
if err := r.addContent(owner, t); err != nil {
|
||||
return err
|
||||
}
|
||||
t.Placeholders = append([]string(nil), t.Placeholders...)
|
||||
r.owners[t.Key] = owner
|
||||
r.byKey[t.Key] = len(r.types)
|
||||
r.types = append(r.types, t)
|
||||
r.indexContent(t)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user