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) } }