signpost.Registry.Pattern now takes an item override only when it starts with a single "/" and holds no surrounding whitespace or control characters (signpost.ValidItemPattern); a javascript: URL or a protocol-relative //host falls through to the next layer.
246 lines
8.5 KiB
Go
246 lines
8.5 KiB
Go
// 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
|
|
}
|