// 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 "". // // The item override is taken only when it is a path (ValidItemPattern): one // that is not, such as a javascript: URL or a protocol-relative //host, is // ignored and the next layer applies, so stored item data cannot point a // resolved URL at another scheme or host. func (r *Registry) Pattern(key, itemOverride string) string { if p := strings.TrimSpace(itemOverride); p != "" && ValidItemPattern(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 } // ValidItemPattern reports whether p may serve as an item's URL pattern // override: it starts with a single "/" (not "//" or "/\", which browsers // read as another host) and holds no whitespace at its ends and no control // characters, which browsers strip before reading a URL. func ValidItemPattern(p string) bool { if p == "" || p[0] != '/' || strings.HasPrefix(p, "//") || strings.HasPrefix(p, `/\`) { return false } if strings.TrimSpace(p) != p { return false } return !strings.ContainsFunc(p, func(r rune) bool { return r < 0x20 || r == 0x7f }) } // 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 }