feat(17-01): menu item type registry and dependent dropdown binding
- pact: HasMenuItemTypes, MenuItemType, MenuItemReference, MenuItemResolve,
ResolvedMenuItem, MenuItemAlternate, DependentOptionsProvider,
DependentOptionSet; Option gains Depth (omitempty)
- signpost (new module): boot-time registry with fail-loud key and pattern
checks, layered URL patterns incl. PatternFrom for list types, host
overrides at menu_items.patterns, Resolve and ReferenceOptions
- party: signpost.Activate after postcard, before Boot
- cabana: dependsOn on dropdowns (boot checks, controller-only), GET
fields/{field}/dependent-options with swag, inventories, conformance case
- companions fixture, TestSignpostSmoke, TestCompanionsDependentSmoke, docs
This commit is contained in:
125
modules/signpost/README.md
Normal file
125
modules/signpost/README.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# signpost
|
||||
|
||||
Menu item type registry that collects the typed menu item types plugins register at boot, layers their URL patterns with host overrides and resolves menu and sitemap items through the owning plugin.
|
||||
|
||||
`import "git.golem15.com/golem15/summercms/modules/signpost"`
|
||||
|
||||
**Name:** A beach signpost points the way to places; the registry tells menus and sitemaps where each item leads.
|
||||
|
||||
## Overview
|
||||
|
||||
`signpost` is the typed successor of WinterCMS's `pages.menuitem.listTypes`, `pages.menuitem.getTypeInfo` and `pages.menuitem.resolveItem` events. A content plugin implements `pact.HasMenuItemTypes` and returns `pact.MenuItemType` values: each carries its key, label, default URL pattern, the records an item can point at (`References`) and a `Resolve` function. [party](../party/README.md) calls `signpost.Activate` once at boot, after every Register and before the first Boot, and the registry is published on the application. Menu and sitemap plugins read it with `app.Lookup[*signpost.Registry]()`, offer its types in their admin forms and resolve their items through it. No string event carries the list, the type info or the resolve call.
|
||||
|
||||
## Role in the framework
|
||||
|
||||
A small registry between content plugins (which own records and their URLs) and consumer plugins (menus, sitemaps). It depends on [backpack](../backpack/README.md) and [pact](../pact/README.md) only, so the framework never imports a plugin. Its one admin binding is `signpost.ReferenceOptions`, which a controller's `pact.DependentOptionsProvider` returns for a reference dropdown that depends on a type dropdown (see [Forms](../../docs/backend/forms.md#dependent-dropdowns)).
|
||||
|
||||
Use cases:
|
||||
|
||||
- Let a blog plugin offer "post", "category" and "all posts" as menu item types without the menu plugin importing it.
|
||||
- Build headless URLs from patterns such as `/blog/:slug`, and let the host application change them per type.
|
||||
- Fill the reference dropdown of a menu entry or a sitemap row with the records of the chosen type.
|
||||
|
||||
## Features
|
||||
|
||||
- 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.
|
||||
- `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.
|
||||
- `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
|
||||
|
||||
A content plugin registers its types:
|
||||
|
||||
```go
|
||||
package blog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
var _ pact.HasMenuItemTypes = (*Plugin)(nil)
|
||||
|
||||
type Plugin struct{}
|
||||
|
||||
func (p *Plugin) MenuItemTypes() []pact.MenuItemType {
|
||||
return []pact.MenuItemType{{
|
||||
Key: "acme-post",
|
||||
Label: "acme.blog::lang.menuitem.post",
|
||||
DefaultPattern: "/blog/:slug",
|
||||
Placeholders: []string{":slug"},
|
||||
References: func(ctx context.Context) ([]pact.MenuItemReference, error) {
|
||||
return []pact.MenuItemReference{{Value: "hello-world", Label: "Hello world"}}, nil
|
||||
},
|
||||
Resolve: func(ctx context.Context, in pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) {
|
||||
url := strings.ReplaceAll(in.Pattern, ":slug", in.Reference)
|
||||
return &pact.ResolvedMenuItem{
|
||||
Title: "Hello world",
|
||||
URL: url,
|
||||
Alternates: []pact.MenuItemAlternate{{Locale: "en", URL: url, Title: "Hello world"}},
|
||||
}, nil
|
||||
},
|
||||
}, {
|
||||
Key: "acme-all-posts",
|
||||
Label: "acme.blog::lang.menuitem.all_posts",
|
||||
DefaultPattern: "/blog/:slug",
|
||||
PatternFrom: "acme-post",
|
||||
Nesting: true,
|
||||
Resolve: resolveAllPosts,
|
||||
}}
|
||||
}
|
||||
```
|
||||
|
||||
A menu plugin resolves an entry and serves the reference dropdown of its form:
|
||||
|
||||
```go
|
||||
reg, _ := app.Lookup[*signpost.Registry]()
|
||||
item, err := reg.Resolve(ctx, entry.Type, entry.Reference, entry.URLPattern, true, "")
|
||||
|
||||
func (c entriesController) DependentOptions(ctx context.Context, field string, values map[string]any) (pact.DependentOptionSet, error) {
|
||||
typ, _ := values["type"].(string)
|
||||
return signpost.ReferenceOptions(ctx, c.registry(), typ)
|
||||
}
|
||||
```
|
||||
|
||||
## API reference
|
||||
|
||||
| Identifier | Description |
|
||||
|------------|-------------|
|
||||
| `signpost.Activate` | Collects the `pact.HasMenuItemTypes` types of the activated plugins, reads the host overrides and publishes one `*signpost.Registry`; party calls it after Register and before Boot. |
|
||||
| `signpost.Registry` | The read-only registry of menu item types and host pattern overrides. |
|
||||
| `signpost.Registry.Types` | Every registered type, in plugin order and then declaration order. |
|
||||
| `signpost.Registry.Type` | The type registered under a key. |
|
||||
| `signpost.Registry.Pattern` | The layered URL pattern of an item: item override, host override, the record type's host override for a list type, default. |
|
||||
| `signpost.Registry.Resolve` | Resolves one item through its type with the layered pattern; an unknown type is an error. |
|
||||
| `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. |
|
||||
|
||||
## Configuration
|
||||
|
||||
The host application overrides a type's URL pattern in `config/menu_items.yaml`:
|
||||
|
||||
```yaml
|
||||
patterns:
|
||||
acme-post: /news/:slug
|
||||
acme-category: /:slug
|
||||
```
|
||||
|
||||
`menu_items.patterns` is a map from type key to pattern, read once at boot. Type keys contain dashes, so the map can only be set in a YAML file: there is no `SUMMER_` environment override for it. An override for a type no plugin registers, or one that does not start with `/`, fails the boot.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- SummerCMS modules: [backpack](../backpack/README.md), [pact](../pact/README.md).
|
||||
- Third-party: none.
|
||||
- Standard library: `context`, `fmt`, `strings`.
|
||||
|
||||
## Testing
|
||||
|
||||
```sh
|
||||
go test ./modules/signpost/...
|
||||
```
|
||||
|
||||
The tests use an in-memory configuration and need no external services.
|
||||
175
modules/signpost/example_test.go
Normal file
175
modules/signpost/example_test.go
Normal file
@@ -0,0 +1,175 @@
|
||||
package signpost_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/signpost"
|
||||
)
|
||||
|
||||
// BlogPost is the example's stand-in for a blog plugin's post model.
|
||||
type BlogPost struct {
|
||||
Slug string
|
||||
Title string
|
||||
UpdatedAt *time.Time
|
||||
}
|
||||
|
||||
// BlogPlugin is an example content plugin that registers two menu item
|
||||
// types: one post, and the list of every post.
|
||||
type BlogPlugin struct{ posts []BlogPost }
|
||||
|
||||
func (BlogPlugin) ID() string { return "acme.blog" }
|
||||
|
||||
var _ pact.HasMenuItemTypes = BlogPlugin{}
|
||||
|
||||
// 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,
|
||||
}}
|
||||
}
|
||||
|
||||
// postReferences offers every post as a reference.
|
||||
func (p BlogPlugin) postReferences(context.Context) ([]pact.MenuItemReference, error) {
|
||||
refs := make([]pact.MenuItemReference, 0, len(p.posts))
|
||||
for _, post := range p.posts {
|
||||
refs = append(refs, pact.MenuItemReference{Value: post.Slug, Label: post.Title})
|
||||
}
|
||||
return refs, nil
|
||||
}
|
||||
|
||||
// resolvePost resolves one post from its slug.
|
||||
func (p BlogPlugin) resolvePost(_ context.Context, in pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) {
|
||||
for _, post := range p.posts {
|
||||
if post.Slug == in.Reference {
|
||||
url := strings.ReplaceAll(in.Pattern, ":slug", post.Slug)
|
||||
return &pact.ResolvedMenuItem{Title: post.Title, URL: url, MTime: post.UpdatedAt,
|
||||
Alternates: []pact.MenuItemAlternate{{Locale: "en", URL: url, Title: post.Title}}}, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("blog: no post %q", in.Reference)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// EntriesController stands for a menu plugin's admin controller whose
|
||||
// reference dropdown depends on its type dropdown.
|
||||
type EntriesController struct{ app *backpack.App }
|
||||
|
||||
// DependentOptions serves the reference dropdown: the references of the
|
||||
// menu item type chosen in the type field, with their depth.
|
||||
func (c EntriesController) DependentOptions(ctx context.Context, field string, values map[string]any) (pact.DependentOptionSet, error) {
|
||||
typ, _ := values["type"].(string)
|
||||
reg, _ := c.app.Lookup[*signpost.Registry]()
|
||||
return signpost.ReferenceOptions(ctx, reg, typ)
|
||||
}
|
||||
|
||||
// exampleApp is an application whose config/menu_items.yaml moves posts to
|
||||
// /news.
|
||||
func exampleApp() *backpack.App {
|
||||
dir, err := os.MkdirTemp("", "signpost-example")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
if err := os.WriteFile(filepath.Join(dir, "menu_items.yaml"), []byte("patterns:\n acme-post: /news/:slug\n"), 0o644); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=testing"}})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return backpack.New(cfg)
|
||||
}
|
||||
|
||||
func ExampleRegistry_Resolve() {
|
||||
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
|
||||
}
|
||||
|
||||
func ExampleReferenceOptions() {
|
||||
app := exampleApp()
|
||||
blog := BlogPlugin{posts: []BlogPost{{Slug: "hello", Title: "Hello"}}}
|
||||
if err := signpost.Activate(app, []BlogPlugin{blog}); err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
entries := EntriesController{app: app}
|
||||
for _, typ := range []string{"acme-post", "acme-all-posts"} {
|
||||
set, err := entries.DependentOptions(context.Background(), "reference", map[string]any{"type": typ})
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
fmt.Println(typ, set.Applies, set.Options)
|
||||
}
|
||||
// Output:
|
||||
// acme-post true [{hello Hello 0}]
|
||||
// acme-all-posts false []
|
||||
}
|
||||
|
||||
// TestBlogPluginResolveAllPosts runs the documented registration and list
|
||||
// resolver directly: the list's items follow the pattern it is handed.
|
||||
func TestBlogPluginResolveAllPosts(t *testing.T) {
|
||||
blog := BlogPlugin{posts: []BlogPost{{Slug: "hello", Title: "Hello"}}}
|
||||
if types := blog.MenuItemTypes(); len(types) != 2 || types[1].PatternFrom != types[0].Key {
|
||||
t.Fatalf("MenuItemTypes = %+v", types)
|
||||
}
|
||||
item, err := blog.resolveAllPosts(context.Background(), pact.MenuItemResolve{Type: "acme-all-posts", Pattern: "/news/:slug"})
|
||||
if err != nil || item.URL != "" || len(item.Items) != 1 || item.Items[0].URL != "/news/hello" || len(item.Items[0].Alternates) != 1 {
|
||||
t.Fatalf("resolveAllPosts = %+v, %v", item, err)
|
||||
}
|
||||
}
|
||||
226
modules/signpost/signpost.go
Normal file
226
modules/signpost/signpost.go
Normal file
@@ -0,0 +1,226 @@
|
||||
// Package signpost is the menu item type registry: it collects the typed menu
|
||||
// item types plugins register through pact.HasMenuItemTypes once at boot,
|
||||
// layers their URL patterns with the host's overrides and resolves items
|
||||
// through the owning plugin.
|
||||
package signpost
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
// ConfigKey is the host configuration key of the URL pattern overrides: a
|
||||
// map from menu item type key to pattern, such as
|
||||
// menu_items.patterns.acme-post: /news/:slug. It is read once at boot.
|
||||
const ConfigKey = "menu_items.patterns"
|
||||
|
||||
// Registry holds every registered menu item type and the host's pattern
|
||||
// overrides. It is built once by Activate and read-only afterwards, so it is
|
||||
// safe for concurrent use.
|
||||
type Registry struct {
|
||||
types []pact.MenuItemType
|
||||
byKey map[string]int
|
||||
owners map[string]string
|
||||
host map[string]string
|
||||
}
|
||||
|
||||
// Activate collects the menu item types of every plugin that implements
|
||||
// pact.HasMenuItemTypes, in plugin order and then declaration order, reads the
|
||||
// host pattern overrides at ConfigKey and publishes one *Registry on app.
|
||||
// Consumers read it with app.Lookup[*signpost.Registry](). Every error is
|
||||
// prefixed "signpost: " and fails the boot: an empty key, a key two plugins
|
||||
// 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 "/".
|
||||
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{}}
|
||||
for _, p := range plugins {
|
||||
src, ok := any(p).(pact.HasMenuItemTypes)
|
||||
if !ok || src == nil {
|
||||
continue
|
||||
}
|
||||
for _, t := range src.MenuItemTypes() {
|
||||
if err := reg.add(p.ID(), t); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := reg.checkPatternFrom(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := reg.loadHost(app); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := app.Publish(reg); err != nil {
|
||||
return fmt.Errorf("signpost: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Registry) add(owner string, t pact.MenuItemType) error {
|
||||
if strings.TrimSpace(t.Key) == "" {
|
||||
return fmt.Errorf("signpost: plugin %s registers a menu item type with an empty key", owner)
|
||||
}
|
||||
if first, dup := r.owners[t.Key]; dup {
|
||||
return fmt.Errorf("signpost: menu item type %q is registered by both %s and %s", t.Key, first, owner)
|
||||
}
|
||||
if t.Resolve == nil {
|
||||
return fmt.Errorf("signpost: menu item type %q of plugin %s has no Resolve", t.Key, owner)
|
||||
}
|
||||
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)
|
||||
}
|
||||
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)
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkPatternFrom runs after every type is collected: a list type may name
|
||||
// only another registered type that is not itself a list type.
|
||||
func (r *Registry) checkPatternFrom() error {
|
||||
for _, t := range r.types {
|
||||
if t.PatternFrom == "" {
|
||||
continue
|
||||
}
|
||||
if t.PatternFrom == t.Key {
|
||||
return fmt.Errorf("signpost: menu item type %q names itself in PatternFrom", t.Key)
|
||||
}
|
||||
target, ok := r.byKey[t.PatternFrom]
|
||||
if !ok {
|
||||
return fmt.Errorf("signpost: menu item type %q has PatternFrom %q, which no plugin registers", t.Key, t.PatternFrom)
|
||||
}
|
||||
if r.types[target].PatternFrom != "" {
|
||||
return fmt.Errorf("signpost: menu item type %q has PatternFrom %q, which sets PatternFrom itself (one level only)", t.Key, t.PatternFrom)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadHost reads the host pattern overrides. An absent key means none.
|
||||
func (r *Registry) loadHost(app *backpack.App) error {
|
||||
if app.Config == nil || !app.Config.Has(ConfigKey) {
|
||||
return nil
|
||||
}
|
||||
patterns := map[string]string{}
|
||||
if err := app.Config.LoadSection(ConfigKey, &patterns); err != nil {
|
||||
return fmt.Errorf("signpost: %s must be a map of menu item type to URL pattern: %w", ConfigKey, err)
|
||||
}
|
||||
for key, pattern := range patterns {
|
||||
if _, ok := r.byKey[key]; !ok {
|
||||
return fmt.Errorf("signpost: %s overrides %q, which no plugin registers", ConfigKey, key)
|
||||
}
|
||||
if !strings.HasPrefix(pattern, "/") {
|
||||
return fmt.Errorf("signpost: %s.%s must start with \"/\", got %q", ConfigKey, key, pattern)
|
||||
}
|
||||
r.host[key] = pattern
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Types returns every registered type in plugin activation order, then
|
||||
// declaration order. The slice is a copy.
|
||||
func (r *Registry) Types() []pact.MenuItemType {
|
||||
if r == nil {
|
||||
return []pact.MenuItemType{}
|
||||
}
|
||||
return append([]pact.MenuItemType{}, r.types...)
|
||||
}
|
||||
|
||||
// Type returns the type registered under key.
|
||||
func (r *Registry) Type(key string) (pact.MenuItemType, bool) {
|
||||
if r == nil {
|
||||
return pact.MenuItemType{}, false
|
||||
}
|
||||
i, ok := r.byKey[key]
|
||||
if !ok {
|
||||
return pact.MenuItemType{}, false
|
||||
}
|
||||
return r.types[i], true
|
||||
}
|
||||
|
||||
// Pattern returns the URL pattern of an item of type key, in layers: the
|
||||
// item's own override when it is not empty, else the host override of key,
|
||||
// else, for a list type that sets PatternFrom, the host override of that
|
||||
// record type, else the type's DefaultPattern. An unknown key without an item
|
||||
// override answers "".
|
||||
func (r *Registry) Pattern(key, itemOverride string) string {
|
||||
if p := strings.TrimSpace(itemOverride); p != "" {
|
||||
return p
|
||||
}
|
||||
if r == nil {
|
||||
return ""
|
||||
}
|
||||
if p, ok := r.host[key]; ok {
|
||||
return p
|
||||
}
|
||||
t, ok := r.Type(key)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
if t.PatternFrom != "" {
|
||||
if p, ok := r.host[t.PatternFrom]; ok {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return t.DefaultPattern
|
||||
}
|
||||
|
||||
// Resolve resolves one item of type key through the type's Resolve, handing
|
||||
// it the layered pattern (Pattern(key, itemPattern)). An unknown key is an
|
||||
// error.
|
||||
func (r *Registry) Resolve(ctx context.Context, key, reference, itemPattern string, nesting bool, unprefixedLocale string) (*pact.ResolvedMenuItem, error) {
|
||||
t, ok := r.Type(key)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("signpost: unknown menu item type %q", key)
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
return t.Resolve(ctx, pact.MenuItemResolve{
|
||||
Type: key,
|
||||
Reference: reference,
|
||||
Pattern: r.Pattern(key, itemPattern),
|
||||
Nesting: nesting,
|
||||
UnprefixedLocale: unprefixedLocale,
|
||||
})
|
||||
}
|
||||
|
||||
// ReferenceOptions is the one admin binding of the registry: the options of a
|
||||
// reference dropdown that depends on a type dropdown. A type the registry
|
||||
// does not know, or one without References, answers Applies false. Otherwise
|
||||
// the reference tree is flattened depth first into options whose Depth is the
|
||||
// nesting level (0 at the top). Labels are passed through as the type returns
|
||||
// them.
|
||||
func ReferenceOptions(ctx context.Context, r *Registry, typeKey string) (pact.DependentOptionSet, error) {
|
||||
t, ok := r.Type(typeKey)
|
||||
if !ok || t.References == nil {
|
||||
return pact.DependentOptionSet{Applies: false, Options: []pact.Option{}}, nil
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
refs, err := t.References(ctx)
|
||||
if err != nil {
|
||||
return pact.DependentOptionSet{}, fmt.Errorf("signpost: references of %q: %w", typeKey, err)
|
||||
}
|
||||
options := []pact.Option{}
|
||||
var walk func(items []pact.MenuItemReference, depth int)
|
||||
walk = func(items []pact.MenuItemReference, depth int) {
|
||||
for _, item := range items {
|
||||
options = append(options, pact.Option{Value: item.Value, Label: item.Label, Depth: depth})
|
||||
walk(item.Items, depth+1)
|
||||
}
|
||||
}
|
||||
walk(refs, 0)
|
||||
return pact.DependentOptionSet{Applies: true, Options: options}, nil
|
||||
}
|
||||
153
modules/signpost/signpost_smoke_test.go
Normal file
153
modules/signpost/signpost_smoke_test.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package signpost_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/signpost"
|
||||
)
|
||||
|
||||
type typesPlugin struct {
|
||||
id string
|
||||
types []pact.MenuItemType
|
||||
}
|
||||
|
||||
func (p typesPlugin) ID() string { return p.id }
|
||||
func (p typesPlugin) MenuItemTypes() []pact.MenuItemType { return p.types }
|
||||
|
||||
// echo resolves an item to its layered pattern, so a test sees the layer the
|
||||
// registry picked.
|
||||
func echo(_ context.Context, in pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) {
|
||||
return &pact.ResolvedMenuItem{Title: in.Type, URL: in.Pattern, Alternates: []pact.MenuItemAlternate{{Locale: "en", URL: in.Pattern}}}, nil
|
||||
}
|
||||
|
||||
func smokeApp(t *testing.T, menuItems string) *backpack.App {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
if menuItems != "" {
|
||||
if err := os.WriteFile(filepath.Join(dir, "menu_items.yaml"), []byte(menuItems), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=testing"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return backpack.New(cfg)
|
||||
}
|
||||
|
||||
func acmeTypes() []pact.MenuItemType {
|
||||
return []pact.MenuItemType{
|
||||
{Key: "acme-post", Label: "Post", DefaultPattern: "/blog/:slug", Resolve: echo,
|
||||
References: func(context.Context) ([]pact.MenuItemReference, error) {
|
||||
return []pact.MenuItemReference{{Value: "1", Label: "First", Items: []pact.MenuItemReference{{Value: "2", Label: "Child"}}}, {Value: "3", Label: "Second"}}, nil
|
||||
}},
|
||||
{Key: "acme-posts", Label: "All posts", DefaultPattern: "/blog/:slug", PatternFrom: "acme-post", Resolve: echo},
|
||||
{Key: "acme-page", Label: "Page", DefaultPattern: "/:slug", Resolve: echo},
|
||||
}
|
||||
}
|
||||
|
||||
// TestSignpostSmoke checks the registry's boot refusals and the D-02 pattern
|
||||
// layers, including a list type that follows its record type's host override.
|
||||
func TestSignpostSmoke(t *testing.T) {
|
||||
t.Run("duplicate key names both plugins", func(t *testing.T) {
|
||||
app := smokeApp(t, "")
|
||||
err := signpost.Activate(app, []typesPlugin{
|
||||
{id: "acme.blog", types: []pact.MenuItemType{{Key: "acme-post", DefaultPattern: "/a", Resolve: echo}}},
|
||||
{id: "acme.news", types: []pact.MenuItemType{{Key: "acme-post", DefaultPattern: "/b", Resolve: echo}}},
|
||||
})
|
||||
if err == nil || !strings.HasPrefix(err.Error(), "signpost: ") || !strings.Contains(err.Error(), "acme.blog") || !strings.Contains(err.Error(), "acme.news") || !strings.Contains(err.Error(), "acme-post") {
|
||||
t.Fatalf("duplicate key error = %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("boot refusals", func(t *testing.T) {
|
||||
for name, types := range map[string][]pact.MenuItemType{
|
||||
"empty key": {{Key: "", DefaultPattern: "/a", Resolve: echo}},
|
||||
"nil resolve": {{Key: "acme-a", DefaultPattern: "/a"}},
|
||||
"empty pattern": {{Key: "acme-a", Resolve: echo}},
|
||||
"relative pattern": {{Key: "acme-a", DefaultPattern: "a/:slug", Resolve: echo}},
|
||||
"dangling PatternFrom": {{Key: "acme-a", DefaultPattern: "/a", PatternFrom: "acme-missing", Resolve: echo}},
|
||||
"self PatternFrom": {{Key: "acme-a", DefaultPattern: "/a", PatternFrom: "acme-a", Resolve: echo}},
|
||||
"chained PatternFrom": {
|
||||
{Key: "acme-a", DefaultPattern: "/a", Resolve: echo},
|
||||
{Key: "acme-b", DefaultPattern: "/b", PatternFrom: "acme-a", Resolve: echo},
|
||||
{Key: "acme-c", DefaultPattern: "/c", PatternFrom: "acme-b", Resolve: echo},
|
||||
},
|
||||
} {
|
||||
err := signpost.Activate(smokeApp(t, ""), []typesPlugin{{id: "acme.blog", types: types}})
|
||||
if err == nil || !strings.HasPrefix(err.Error(), "signpost: ") {
|
||||
t.Fatalf("%s: error = %v", name, err)
|
||||
}
|
||||
}
|
||||
err := signpost.Activate(smokeApp(t, "patterns:\n acme-unknown: /x\n"), []typesPlugin{{id: "acme.blog", types: acmeTypes()}})
|
||||
if err == nil || !strings.Contains(err.Error(), "acme-unknown") {
|
||||
t.Fatalf("unknown host override error = %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("pattern layers", func(t *testing.T) {
|
||||
app := smokeApp(t, "patterns:\n acme-post: /news/:slug\n acme-page: /p/:slug\n")
|
||||
if err := signpost.Activate(app, []typesPlugin{{id: "acme.blog", types: acmeTypes()}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reg, ok := app.Lookup[*signpost.Registry]()
|
||||
if !ok || len(reg.Types()) != 3 {
|
||||
t.Fatalf("registry not published: %v", ok)
|
||||
}
|
||||
for _, tc := range []struct{ key, item, want string }{
|
||||
{"acme-page", "", "/p/:slug"}, // host override
|
||||
{"acme-page", "/custom/:slug", "/custom/:slug"}, // item override beats host
|
||||
{"acme-post", "", "/news/:slug"}, // host override
|
||||
{"acme-posts", "", "/news/:slug"}, // list type follows its record type's host override
|
||||
{"acme-posts", "/mine/:slug", "/mine/:slug"}, // item override beats both
|
||||
{"acme-unknown", "", ""},
|
||||
} {
|
||||
if got := reg.Pattern(tc.key, tc.item); got != tc.want {
|
||||
t.Fatalf("Pattern(%q, %q) = %q, want %q", tc.key, tc.item, got, tc.want)
|
||||
}
|
||||
}
|
||||
resolved, err := reg.Resolve(context.Background(), "acme-posts", "", "", false, "")
|
||||
if err != nil || resolved.URL != "/news/:slug" {
|
||||
t.Fatalf("Resolve = %+v, %v", resolved, err)
|
||||
}
|
||||
if _, err := reg.Resolve(context.Background(), "acme-unknown", "", "", false, ""); err == nil {
|
||||
t.Fatal("unknown type resolved")
|
||||
}
|
||||
set, err := signpost.ReferenceOptions(context.Background(), reg, "acme-post")
|
||||
if err != nil || !set.Applies || len(set.Options) != 3 || set.Options[1].Depth != 1 || set.Options[2].Depth != 0 {
|
||||
t.Fatalf("ReferenceOptions = %+v, %v", set, err)
|
||||
}
|
||||
none, err := signpost.ReferenceOptions(context.Background(), reg, "acme-page")
|
||||
if err != nil || none.Applies {
|
||||
t.Fatalf("type without references = %+v, %v", none, err)
|
||||
}
|
||||
})
|
||||
t.Run("host override of the list type beats its record type", func(t *testing.T) {
|
||||
app := smokeApp(t, "patterns:\n acme-post: /news/:slug\n acme-posts: /all/:slug\n")
|
||||
if err := signpost.Activate(app, []typesPlugin{{id: "acme.blog", types: acmeTypes()}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reg, _ := app.Lookup[*signpost.Registry]()
|
||||
if got := reg.Pattern("acme-posts", ""); got != "/all/:slug" {
|
||||
t.Fatalf("list type host override = %q", got)
|
||||
}
|
||||
if got := reg.Pattern("acme-posts", "/item/:slug"); got != "/item/:slug" {
|
||||
t.Fatalf("item override = %q", got)
|
||||
}
|
||||
})
|
||||
t.Run("default pattern without host config", func(t *testing.T) {
|
||||
app := smokeApp(t, "")
|
||||
if err := signpost.Activate(app, []typesPlugin{{id: "acme.blog", types: acmeTypes()}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reg, _ := app.Lookup[*signpost.Registry]()
|
||||
if got := reg.Pattern("acme-posts", ""); got != "/blog/:slug" {
|
||||
t.Fatalf("default = %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user