- GET /_menu/api/v1/menus/{code}?locale=xx, anonymous, menu-public-api bucket (120/min per client IP)
- classes.BuildTree: published entries nested by parent, sort_order then id, scheme-checked URLs
- registry entries resolved through signpost with url_pattern as the item override and the locale's alternate
- list types keep stored URL and title, generated children follow stored ones; unresolvable entries left out
- TestMenuAPISmoke; README with API, CORS paths, import workflow and Winter adaptations
- go mod tidy records the test dependencies
228 lines
6.8 KiB
Go
228 lines
6.8 KiB
Go
package classes
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
|
|
"git.golem15.com/golem15/sm-menu-plugin/models"
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
)
|
|
|
|
// Item is one entry of the public menu tree (GET /_menu/api/v1/menus/{code}).
|
|
// ID is the entry id, nil for a child a registry type generated. Type is the
|
|
// stored entry type; a generated child carries its parent's registry type.
|
|
// URL is nil for dividers and for links that fail AllowedURL. Target is
|
|
// _self, _blank or nil. CSSClass, Icon and Code are nil when empty. Items is
|
|
// never nil, so it encodes as [].
|
|
type Item struct {
|
|
ID *uint `json:"id"`
|
|
Title string `json:"title"`
|
|
Type string `json:"type"`
|
|
URL *string `json:"url"`
|
|
Target *string `json:"target"`
|
|
CSSClass *string `json:"css_class"`
|
|
Icon *string `json:"icon"`
|
|
Code *string `json:"code"`
|
|
Items []Item `json:"items"`
|
|
}
|
|
|
|
// Resolver is the part of the framework's menu item type registry the tree
|
|
// reads; *signpost.Registry implements it.
|
|
type Resolver interface {
|
|
Type(key string) (pact.MenuItemType, bool)
|
|
Resolve(ctx context.Context, key, reference, itemPattern string, nesting bool, unprefixedLocale string) (*pact.ResolvedMenuItem, error)
|
|
}
|
|
|
|
// TreeOptions are the request inputs of BuildTree.
|
|
type TreeOptions struct {
|
|
// Registry resolves registry entries; nil leaves every registry entry
|
|
// out.
|
|
Registry Resolver
|
|
// Locale picks the alternate of a resolved item whose locale matches;
|
|
// "" or a locale without an alternate keeps the resolved URL and title.
|
|
Locale string
|
|
// Logger records entries left out; nil uses slog.Default.
|
|
Logger *slog.Logger
|
|
}
|
|
|
|
// BuildTree nests a menu's entries under their parents. entries must be the
|
|
// published entries of one menu ordered by sort_order then id; that order
|
|
// is kept within each parent. An entry whose parent is not among entries
|
|
// (unpublished or missing) is unreachable and left out with its subtree.
|
|
//
|
|
// Built-in types: a url entry serves its stored URL when AllowedURL accepts
|
|
// it (else nil), a divider a nil URL. A registry entry is resolved through
|
|
// opts.Registry with its reference, its url_pattern as the item override and
|
|
// its type's Nesting. When the resolved item has a URL (a single type), the
|
|
// entry serves the URL of the alternate for opts.Locale, else the resolved
|
|
// URL, and its stored title when not empty, else the resolved title for the
|
|
// locale. When the resolved item has no URL (a list type), the entry keeps
|
|
// its stored URL (scheme-checked) and title. The resolved sub-items follow
|
|
// the stored children in Items as generated children. An entry whose type is
|
|
// not registered or whose reference no longer resolves is left out with its
|
|
// subtree and logged.
|
|
func BuildTree(ctx context.Context, entries []models.MenuEntry, opts TreeOptions) []Item {
|
|
b := treeBuilder{ctx: ctx, opts: opts, children: map[uint][]*models.MenuEntry{}, seen: map[uint]bool{}}
|
|
if b.ctx == nil {
|
|
b.ctx = context.Background()
|
|
}
|
|
if b.opts.Logger == nil {
|
|
b.opts.Logger = slog.Default()
|
|
}
|
|
var roots []*models.MenuEntry
|
|
for i := range entries {
|
|
e := &entries[i]
|
|
if e.ParentID == nil || *e.ParentID == 0 {
|
|
roots = append(roots, e)
|
|
continue
|
|
}
|
|
b.children[*e.ParentID] = append(b.children[*e.ParentID], e)
|
|
}
|
|
return b.items(roots)
|
|
}
|
|
|
|
type treeBuilder struct {
|
|
ctx context.Context
|
|
opts TreeOptions
|
|
children map[uint][]*models.MenuEntry
|
|
seen map[uint]bool
|
|
}
|
|
|
|
func (b *treeBuilder) items(entries []*models.MenuEntry) []Item {
|
|
out := []Item{}
|
|
for _, e := range entries {
|
|
if b.seen[e.ID] {
|
|
continue
|
|
}
|
|
b.seen[e.ID] = true
|
|
if item, ok := b.item(e); ok {
|
|
out = append(out, item)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (b *treeBuilder) item(e *models.MenuEntry) (Item, bool) {
|
|
id := e.ID
|
|
item := Item{
|
|
ID: &id,
|
|
Title: models.Text(e.Title),
|
|
Type: e.Type,
|
|
Target: target(e.Target),
|
|
CSSClass: optional(e.CSSClass),
|
|
Icon: optional(e.Icon),
|
|
Code: optional(e.Code),
|
|
}
|
|
var generated []Item
|
|
switch e.Type {
|
|
case models.TypeURL:
|
|
item.URL = allowed(models.Text(e.URL))
|
|
case models.TypeDivider:
|
|
default:
|
|
resolved, ok := b.resolve(e)
|
|
if !ok {
|
|
return Item{}, false
|
|
}
|
|
if resolved.URL == "" {
|
|
item.URL = allowed(models.Text(e.URL))
|
|
} else {
|
|
url, title := pick(resolved, b.opts.Locale)
|
|
item.URL = allowed(url)
|
|
if item.Title == "" {
|
|
item.Title = title
|
|
}
|
|
}
|
|
generated = b.generated(e.Type, resolved.Items)
|
|
}
|
|
item.Items = append(b.items(b.children[e.ID]), generated...)
|
|
return item, true
|
|
}
|
|
|
|
// resolve resolves a registry entry; false leaves it out.
|
|
func (b *treeBuilder) resolve(e *models.MenuEntry) (*pact.ResolvedMenuItem, bool) {
|
|
reg := b.opts.Registry
|
|
if reg == nil {
|
|
b.opts.Logger.Warn("menu: entry left out: no menu item type registry", "entry", e.ID, "type", e.Type)
|
|
return nil, false
|
|
}
|
|
t, ok := reg.Type(e.Type)
|
|
if !ok {
|
|
b.opts.Logger.Warn("menu: entry left out: menu item type is not registered", "entry", e.ID, "type", e.Type)
|
|
return nil, false
|
|
}
|
|
resolved, err := reg.Resolve(b.ctx, e.Type, models.Text(e.Reference), models.Text(e.URLPattern), t.Nesting, "")
|
|
if err != nil {
|
|
b.opts.Logger.Warn("menu: entry left out: resolve failed", "entry", e.ID, "type", e.Type, "reference", models.Text(e.Reference), "error", err)
|
|
return nil, false
|
|
}
|
|
if resolved == nil {
|
|
b.opts.Logger.Warn("menu: entry left out: reference does not resolve", "entry", e.ID, "type", e.Type, "reference", models.Text(e.Reference))
|
|
return nil, false
|
|
}
|
|
return resolved, true
|
|
}
|
|
|
|
// generated turns resolved sub-items into children without an id, carrying
|
|
// the parent's registry type.
|
|
func (b *treeBuilder) generated(typ string, resolved []pact.ResolvedMenuItem) []Item {
|
|
out := make([]Item, 0, len(resolved))
|
|
for i := range resolved {
|
|
url, title := pick(&resolved[i], b.opts.Locale)
|
|
out = append(out, Item{
|
|
Title: title,
|
|
Type: typ,
|
|
URL: allowed(url),
|
|
Items: b.generated(typ, resolved[i].Items),
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// pick returns the URL and title of the alternate for locale, falling back
|
|
// to the resolved URL and title for a missing locale or empty values.
|
|
func pick(r *pact.ResolvedMenuItem, locale string) (url, title string) {
|
|
url, title = r.URL, r.Title
|
|
if locale == "" {
|
|
return url, title
|
|
}
|
|
for _, alt := range r.Alternates {
|
|
if alt.Locale != locale {
|
|
continue
|
|
}
|
|
if alt.URL != "" {
|
|
url = alt.URL
|
|
}
|
|
if alt.Title != "" {
|
|
title = alt.Title
|
|
}
|
|
break
|
|
}
|
|
return url, title
|
|
}
|
|
|
|
// allowed is url when it is not empty and AllowedURL accepts it, else nil.
|
|
func allowed(url string) *string {
|
|
if url == "" || !AllowedURL(url) {
|
|
return nil
|
|
}
|
|
return &url
|
|
}
|
|
|
|
// target is the stored target when it is _self or _blank, else nil.
|
|
func target(s *string) *string {
|
|
v := models.Text(s)
|
|
if v == "" || !AllowedTarget(v) {
|
|
return nil
|
|
}
|
|
return &v
|
|
}
|
|
|
|
// optional is the stored value, nil when null or empty.
|
|
func optional(s *string) *string {
|
|
if s == nil || *s == "" {
|
|
return nil
|
|
}
|
|
v := *s
|
|
return &v
|
|
}
|