Files
sm-menu-plugin/classes/tree.go
Jakub Zych 33a65ad802 feat(17-04): public menu API with registry resolution, throttle bucket and README
- 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
2026-10-12 17:51:27 +02:00

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
}