Files
summercms/modules/signpost/content.go
Jakub Zych 785902c612 feat(32-02): signpost content lookups, Records and MatchPath
- ContentType, ContentTypeForController, Records and MatchPath on the
  registry; the pure MatchPattern for whole-segment placeholder matching
- two types declaring the same Content.Model or Controller stop the boot
- TestContent* cover lookups, optional Records, boot errors, MatchPattern
  and MatchPath precedence (first exact, else first inexact)
- docs: Content descriptors in menu-item-types.md, signpost README
2026-10-26 22:39:35 +01:00

230 lines
7.2 KiB
Go

package signpost
import (
"context"
"fmt"
"strings"
"git.golem15.com/golem15/summercms/modules/pact"
)
// addContent validates a type's content descriptor before the type is
// added: Model and Describe are required when Content is set, and no two
// types may describe the same Model or name the same non-empty Controller.
func (r *Registry) addContent(owner string, t pact.MenuItemType) error {
c := t.Content
if c == nil {
return nil
}
if strings.TrimSpace(c.Model) == "" {
return fmt.Errorf("signpost: menu item type %q of plugin %s has a content descriptor without Model", t.Key, owner)
}
if c.Describe == nil {
return fmt.Errorf("signpost: menu item type %q of plugin %s has a content descriptor without Describe", t.Key, owner)
}
if first, dup := r.byModel[c.Model]; dup {
return fmt.Errorf("signpost: menu item type %q of plugin %s describes model %q, which %q of plugin %s already describes", t.Key, owner, c.Model, first, r.owners[first])
}
if c.Controller != "" {
if first, dup := r.byController[c.Controller]; dup {
return fmt.Errorf("signpost: menu item type %q of plugin %s names controller %q, which %q of plugin %s already names", t.Key, owner, c.Controller, first, r.owners[first])
}
}
return nil
}
// indexContent records a type's descriptor once the type is added.
func (r *Registry) indexContent(t pact.MenuItemType) {
if t.Content == nil {
return
}
r.byModel[t.Content.Model] = t.Key
if t.Content.Controller != "" {
r.byController[t.Content.Controller] = t.Key
}
}
// ContentTypes returns the types that carry a content descriptor
// (pact.MenuItemType.Content), in registration order. The slice is a copy.
func (r *Registry) ContentTypes() []pact.MenuItemType {
out := []pact.MenuItemType{}
if r == nil {
return out
}
for _, t := range r.types {
if t.Content != nil {
out = append(out, t)
}
}
return out
}
// ContentType returns the type registered under key when it carries a
// content descriptor.
func (r *Registry) ContentType(key string) (pact.MenuItemType, bool) {
t, ok := r.Type(key)
if !ok || t.Content == nil {
return pact.MenuItemType{}, false
}
return t, true
}
// ContentTypeForController returns the content type whose descriptor names
// the admin controller id, the controller whose form edits its records.
func (r *Registry) ContentTypeForController(id string) (pact.MenuItemType, bool) {
if r == nil || id == "" {
return pact.MenuItemType{}, false
}
key, ok := r.byController[id]
if !ok {
return pact.MenuItemType{}, false
}
return r.Type(key)
}
// ContentTypeForModel returns the content type whose descriptor declares
// model, the morph type record events carry (pact.RecordSaved.Model).
func (r *Registry) ContentTypeForModel(model string) (pact.MenuItemType, bool) {
if r == nil || model == "" {
return pact.MenuItemType{}, false
}
key, ok := r.byModel[model]
if !ok {
return pact.MenuItemType{}, false
}
return r.Type(key)
}
// Describe describes one record of the content type key through its
// descriptor, handing it the layered pattern (Pattern(key, "")). An unknown
// key or a type without a content descriptor is an error; a record the type
// does not know answers nil.
func (r *Registry) Describe(ctx context.Context, key, id, unprefixedLocale string) (*pact.ContentRecord, error) {
t, err := r.contentType(key)
if err != nil {
return nil, err
}
if ctx == nil {
ctx = context.Background()
}
return t.Content.Describe(ctx, pact.ContentDescribe{
ID: id,
Pattern: r.Pattern(key, ""),
UnprefixedLocale: unprefixedLocale,
})
}
// Records pages through the records of the content type key: after is the
// ID of the last record of the previous page ("" for the first), limit the
// page size the caller wants. The descriptor gets the layered pattern
// (Pattern(key, "")). A content type without Records answers an empty
// page; an unknown key or a type without a content descriptor is an error.
func (r *Registry) Records(ctx context.Context, key, after string, limit int, unprefixedLocale string) (pact.ContentPage, error) {
t, err := r.contentType(key)
if err != nil {
return pact.ContentPage{}, err
}
if t.Content.Records == nil {
return pact.ContentPage{Records: []pact.ContentRecord{}}, nil
}
if ctx == nil {
ctx = context.Background()
}
return t.Content.Records(ctx, pact.ContentList{
Pattern: r.Pattern(key, ""),
After: after,
Limit: limit,
UnprefixedLocale: unprefixedLocale,
})
}
// MatchPath finds the record a public path belongs to: it asks every
// content type with Match, in registration order, with the type's layered
// pattern. The first exact match wins; without one, the first non-exact
// match (a record the path names with a segment that does not fit it) is
// returned; without any, key is "" and res nil. A Match error stops the
// search and is returned.
func (r *Registry) MatchPath(ctx context.Context, path, unprefixedLocale string) (key string, res *pact.ContentMatchResult, err error) {
if r == nil {
return "", nil, nil
}
if ctx == nil {
ctx = context.Background()
}
var (
fallbackKey string
fallback *pact.ContentMatchResult
)
for _, t := range r.types {
if t.Content == nil || t.Content.Match == nil {
continue
}
got, err := t.Content.Match(ctx, pact.ContentMatch{
Path: path,
Pattern: r.Pattern(t.Key, ""),
UnprefixedLocale: unprefixedLocale,
})
if err != nil {
return "", nil, fmt.Errorf("signpost: match %q in menu item type %q: %w", path, t.Key, err)
}
if got == nil {
continue
}
if got.Exact {
return t.Key, got, nil
}
if fallback == nil {
fallbackKey, fallback = t.Key, got
}
}
return fallbackKey, fallback, nil
}
// MatchPattern matches a path against a URL pattern such as
// "/blog/:category/:slug" and returns the placeholder values. A placeholder
// is a whole segment starting with ":"; every other segment must equal the
// path's, the path must have exactly as many segments, a placeholder never
// matches an empty segment, and a placeholder used twice must match the
// same value. Segments are compared as given: the caller decodes the path.
// Content types use it in Match to read a path's values.
func MatchPattern(pattern, path string) (map[string]string, bool) {
if !strings.HasPrefix(pattern, "/") || !strings.HasPrefix(path, "/") {
return nil, false
}
want := strings.Split(pattern[1:], "/")
got := strings.Split(path[1:], "/")
if len(want) != len(got) {
return nil, false
}
values := map[string]string{}
for i, seg := range want {
if len(seg) > 1 && seg[0] == ':' {
name := seg[1:]
if got[i] == "" {
return nil, false
}
if prev, seen := values[name]; seen && prev != got[i] {
return nil, false
}
values[name] = got[i]
continue
}
if seg != got[i] {
return nil, false
}
}
return values, true
}
// contentType is the type key with its content descriptor.
func (r *Registry) contentType(key string) (pact.MenuItemType, error) {
t, ok := r.Type(key)
if !ok {
return pact.MenuItemType{}, fmt.Errorf("signpost: unknown menu item type %q", key)
}
if t.Content == nil {
return pact.MenuItemType{}, fmt.Errorf("signpost: menu item type %q has no content descriptor", key)
}
return t, nil
}