feat(signpost): content descriptor fields for dates, parents, authors, media and outline
- pact.ContentDescriptor gains Kind and Tools with the ContentKind and ContentTool constants - pact.ContentRecord gains PublishedAt, Parents, Authors and TopHeading; pact.ContentLocale gains Images, Videos and Headings, with the value types ContentRef, ContentPerson, ContentImage, ContentVideo and ContentHeading; every field is optional - TestContentDescriptorFields proves signpost hands them through and that a type setting none of them describes as before - the runnable content example, the pact and signpost READMEs and the menu item types page document the fields
This commit is contained in:
@@ -25,7 +25,7 @@ Use cases:
|
||||
- Boot-time collection in plugin order, then declaration order. Every error is prefixed `signpost:` and fails the boot: an empty key, a key two plugins register (the message names the key and 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` too, and a host override for an unknown type or one that does not start with `/`.
|
||||
- URL patterns in layers (`signpost.Registry.Pattern`): the item's own override, else the host override of the type, else, for a list type that sets `PatternFrom`, the host override of the record type it lists, else the type's `DefaultPattern`. A host that changes the post URL once therefore also changes the children of every list of posts, unless the list type itself is overridden. An item override is taken only when it is a path (`signpost.ValidItemPattern`): it starts with a single `/` and has no surrounding whitespace or control characters. Any other override, such as a `javascript:` URL or a protocol-relative `//host`, is ignored and the next layer applies.
|
||||
- `signpost.Registry.Resolve` hands the layered pattern to the type's `Resolve` in `pact.MenuItemResolve.Pattern`. The result, `pact.ResolvedMenuItem`, carries the title, URL, modification time, nested items, and the alternates per locale as an ordered `[]pact.MenuItemAlternate`, never a map.
|
||||
- Content descriptors: a record type that sets `pact.MenuItemType.Content` describes its records to consumers that work on every content type at once. `signpost.Registry.ContentTypes` lists those types, `signpost.Registry.ContentType`, `signpost.Registry.ContentTypeForController` and `signpost.Registry.ContentTypeForModel` find one by key, admin controller ID or the morph type a record event carries (`pact.RecordSaved.Model`), `signpost.Registry.Describe` describes one record and `signpost.Registry.Records` pages through them with the layered pattern. `signpost.Registry.MatchPath` maps a public path to its record: the first exact match in registration order, else the first inexact one (a path that names the record with a segment that does not fit it). `signpost.MatchPattern` is the whole-segment matcher content types read placeholder values with. A descriptor without `Model` or `Describe`, and two types declaring the same `Model` or `Controller`, fail the boot.
|
||||
- Content descriptors: a record type that sets `pact.MenuItemType.Content` describes its records to consumers that work on every content type at once. `signpost.Registry.ContentTypes` lists those types, `signpost.Registry.ContentType`, `signpost.Registry.ContentTypeForController` and `signpost.Registry.ContentTypeForModel` find one by key, admin controller ID or the morph type a record event carries (`pact.RecordSaved.Model`), `signpost.Registry.Describe` describes one record and `signpost.Registry.Records` pages through them with the layered pattern. `signpost.Registry.MatchPath` maps a public path to its record: the first exact match in registration order, else the first inexact one (a path that names the record with a segment that does not fit it). `signpost.MatchPattern` is the whole-segment matcher content types read placeholder values with. The descriptor's optional facts (`pact.ContentDescriptor.Kind` and `Tools`, and a record's first publication, parents, authors, images, videos and heading outline) reach every consumer of `Describe` and `Records` as the type answers them. A descriptor without `Model` or `Describe`, and two types declaring the same `Model` or `Controller`, fail the boot.
|
||||
- `signpost.ReferenceOptions` flattens a type's reference tree depth first into dropdown options with a `Depth`, and answers `Applies` false for a type that takes no reference or that no plugin registers.
|
||||
|
||||
## Usage
|
||||
@@ -117,6 +117,8 @@ A record type adds a `pact.ContentDescriptor`: the admin controller that edits t
|
||||
}
|
||||
```
|
||||
|
||||
The descriptor may also say what kind of record the type holds and which MCP tool edits one (`Kind: pact.ContentKindArticle`, `Tools: map[string]string{pact.ContentToolEdit: "acme_update_post"}`), and `Describe` may report the record's `PublishedAt`, `Parents`, `Authors` and `TopHeading`, and per locale its `Images`, `Videos` and `Headings` (see [Content descriptors](../../docs/plugins/menu-item-types.md#content-descriptors)). Every one of them is optional.
|
||||
|
||||
A consumer maps a record event to its type and reads the record's new URL:
|
||||
|
||||
```go
|
||||
|
||||
199
modules/signpost/content_fields_smoke_test.go
Normal file
199
modules/signpost/content_fields_smoke_test.go
Normal file
@@ -0,0 +1,199 @@
|
||||
package signpost_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
// fullRecord is a record that sets every optional descriptor fact: first
|
||||
// publication, two candidate parents, an author with a page of their own,
|
||||
// two images (one decorative with an empty alt), a video and a heading
|
||||
// outline under a level-2 top heading.
|
||||
func fullRecord(id string) *pact.ContentRecord {
|
||||
published := time.Date(2026, 10, 1, 9, 30, 0, 0, time.UTC)
|
||||
changed := time.Date(2026, 10, 3, 12, 0, 0, 0, time.UTC)
|
||||
words := 420
|
||||
return &pact.ContentRecord{
|
||||
ID: id,
|
||||
Published: true,
|
||||
LastMod: &changed,
|
||||
PublishedAt: &published,
|
||||
Parents: []pact.ContentRef{
|
||||
{Type: "acme-category", ID: "3"},
|
||||
{Type: "acme-category", ID: "8"},
|
||||
},
|
||||
Authors: []pact.ContentPerson{{Name: "Ada Lovelace", Ref: pact.ContentRef{Type: "acme-author", ID: "12"}}},
|
||||
TopHeading: 2,
|
||||
Locales: []pact.ContentLocale{{
|
||||
Locale: "en",
|
||||
URL: "/articles/" + id,
|
||||
Title: "Engines",
|
||||
Words: &words,
|
||||
Images: []pact.ContentImage{
|
||||
{URL: "/media/engine.jpg", Alt: "The analytical engine", Width: 1200, Height: 800},
|
||||
{URL: "/media/rule.png", Decorative: true},
|
||||
},
|
||||
Videos: []pact.ContentVideo{{Provider: "youtube", ID: "abc123"}},
|
||||
Headings: []pact.ContentHeading{
|
||||
{Level: 2, Text: "Origins"},
|
||||
{Level: 3, Text: "The mill"},
|
||||
{Level: 2, Text: "Legacy"},
|
||||
},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// TestContentDescriptorFields: a type that sets the optional descriptor
|
||||
// facts (Kind, Tools and the record and locale fields) hands every value
|
||||
// through signpost.Describe and signpost.Records unchanged, and ContentType
|
||||
// exposes Kind and Tools; a type that sets none of them describes exactly
|
||||
// as before, with nil and empty slices treated alike.
|
||||
func TestContentDescriptorFields(t *testing.T) {
|
||||
full := pact.MenuItemType{
|
||||
Key: "acme-article",
|
||||
DefaultPattern: "/articles/:slug",
|
||||
Resolve: echo,
|
||||
Content: &pact.ContentDescriptor{
|
||||
Model: "acme_articles",
|
||||
Kind: pact.ContentKindArticle,
|
||||
Tools: map[string]string{pact.ContentToolEdit: "acme_update_page"},
|
||||
Describe: func(_ context.Context, in pact.ContentDescribe) (*pact.ContentRecord, error) {
|
||||
return fullRecord(in.ID), nil
|
||||
},
|
||||
Records: func(_ context.Context, in pact.ContentList) (pact.ContentPage, error) {
|
||||
return pact.ContentPage{Records: []pact.ContentRecord{*fullRecord("1")}}, nil
|
||||
},
|
||||
},
|
||||
}
|
||||
plainRecord := func(id string) *pact.ContentRecord {
|
||||
return &pact.ContentRecord{ID: id, Published: true, Locales: []pact.ContentLocale{{Locale: "en", URL: "/pages/" + id, Title: "About"}}}
|
||||
}
|
||||
plain := pact.MenuItemType{
|
||||
Key: "acme-page",
|
||||
DefaultPattern: "/pages/:slug",
|
||||
Resolve: echo,
|
||||
Content: &pact.ContentDescriptor{
|
||||
Model: "acme_pages",
|
||||
Describe: func(_ context.Context, in pact.ContentDescribe) (*pact.ContentRecord, error) {
|
||||
return plainRecord(in.ID), nil
|
||||
},
|
||||
Records: func(_ context.Context, in pact.ContentList) (pact.ContentPage, error) {
|
||||
return pact.ContentPage{Records: []pact.ContentRecord{*plainRecord("1")}}, nil
|
||||
},
|
||||
},
|
||||
}
|
||||
reg := contentRegistry(t, "", full, plain)
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("full type hands every value through Describe", func(t *testing.T) {
|
||||
got, err := reg.Describe(ctx, "acme-article", "5", "")
|
||||
if err != nil {
|
||||
t.Fatalf("Describe: %v", err)
|
||||
}
|
||||
if want := fullRecord("5"); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("Describe = %+v, want %+v", got, want)
|
||||
}
|
||||
if got.TopHeading != 2 || len(got.Parents) != 2 || got.Parents[0].ID != "3" {
|
||||
t.Fatalf("record facts lost: TopHeading %d, Parents %+v", got.TopHeading, got.Parents)
|
||||
}
|
||||
if a := got.Authors[0]; a.Name != "Ada Lovelace" || a.Ref != (pact.ContentRef{Type: "acme-author", ID: "12"}) {
|
||||
t.Fatalf("author = %+v", a)
|
||||
}
|
||||
loc := got.Locales[0]
|
||||
if len(loc.Images) != 2 || !loc.Images[1].Decorative || loc.Images[1].Alt != "" || loc.Images[0].Width != 1200 {
|
||||
t.Fatalf("images = %+v", loc.Images)
|
||||
}
|
||||
if len(loc.Videos) != 1 || loc.Videos[0] != (pact.ContentVideo{Provider: "youtube", ID: "abc123"}) {
|
||||
t.Fatalf("videos = %+v", loc.Videos)
|
||||
}
|
||||
if len(loc.Headings) != 3 || loc.Headings[1] != (pact.ContentHeading{Level: 3, Text: "The mill"}) {
|
||||
t.Fatalf("headings = %+v", loc.Headings)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("full type hands every value through Records", func(t *testing.T) {
|
||||
page, err := reg.Records(ctx, "acme-article", "", 10, "")
|
||||
if err != nil {
|
||||
t.Fatalf("Records: %v", err)
|
||||
}
|
||||
if len(page.Records) != 1 || !reflect.DeepEqual(page.Records[0], *fullRecord("1")) {
|
||||
t.Fatalf("Records = %+v", page.Records)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ContentType exposes Kind and Tools", func(t *testing.T) {
|
||||
typ, ok := reg.ContentType("acme-article")
|
||||
if !ok {
|
||||
t.Fatal("ContentType(acme-article) not found")
|
||||
}
|
||||
if typ.Content.Kind != "article" {
|
||||
t.Fatalf("Kind = %q", typ.Content.Kind)
|
||||
}
|
||||
if !reflect.DeepEqual(typ.Content.Tools, map[string]string{"edit": "acme_update_page"}) {
|
||||
t.Fatalf("Tools = %v", typ.Content.Tools)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("type without the facts describes as before", func(t *testing.T) {
|
||||
got, err := reg.Describe(ctx, "acme-page", "4", "")
|
||||
if err != nil {
|
||||
t.Fatalf("Describe: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, plainRecord("4")) {
|
||||
t.Fatalf("Describe = %+v", got)
|
||||
}
|
||||
if got.PublishedAt != nil || got.Parents != nil || got.Authors != nil || got.TopHeading != 0 {
|
||||
t.Fatalf("plain record gained facts: %+v", got)
|
||||
}
|
||||
loc := got.Locales[0]
|
||||
if loc.Images != nil || loc.Videos != nil || loc.Headings != nil {
|
||||
t.Fatalf("plain locale gained facts: %+v", loc)
|
||||
}
|
||||
page, err := reg.Records(ctx, "acme-page", "", 10, "")
|
||||
if err != nil || len(page.Records) != 1 || !reflect.DeepEqual(page.Records[0], *plainRecord("1")) {
|
||||
t.Fatalf("Records = %+v, %v", page, err)
|
||||
}
|
||||
typ, _ := reg.ContentType("acme-page")
|
||||
if typ.Content.Kind != "" || typ.Content.Tools != nil {
|
||||
t.Fatalf("plain type: Kind %q, Tools %v", typ.Content.Kind, typ.Content.Tools)
|
||||
}
|
||||
if k, _ := reg.ContentTypeForModel("acme_pages"); k.Key != "acme-page" {
|
||||
t.Fatalf("ContentTypeForModel = %q", k.Key)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil and empty slices are handed through alike", func(t *testing.T) {
|
||||
empty := pact.MenuItemType{
|
||||
Key: "acme-note",
|
||||
DefaultPattern: "/notes/:slug",
|
||||
Resolve: echo,
|
||||
Content: &pact.ContentDescriptor{
|
||||
Model: "acme_notes",
|
||||
Tools: map[string]string{},
|
||||
Describe: func(_ context.Context, in pact.ContentDescribe) (*pact.ContentRecord, error) {
|
||||
return &pact.ContentRecord{
|
||||
ID: in.ID, Parents: []pact.ContentRef{}, Authors: []pact.ContentPerson{},
|
||||
Locales: []pact.ContentLocale{{Locale: "en", Images: []pact.ContentImage{}, Videos: []pact.ContentVideo{}, Headings: []pact.ContentHeading{}}},
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
}
|
||||
r := contentRegistry(t, "", empty)
|
||||
got, err := r.Describe(ctx, "acme-note", "1", "")
|
||||
if err != nil {
|
||||
t.Fatalf("Describe: %v", err)
|
||||
}
|
||||
if len(got.Parents) != 0 || len(got.Authors) != 0 || len(got.Locales[0].Images) != 0 ||
|
||||
len(got.Locales[0].Videos) != 0 || len(got.Locales[0].Headings) != 0 {
|
||||
t.Fatalf("empty slices grew: %+v", got)
|
||||
}
|
||||
typ, _ := r.ContentType("acme-note")
|
||||
if len(typ.Content.Tools) != 0 {
|
||||
t.Fatalf("Tools = %v", typ.Content.Tools)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/signpost"
|
||||
@@ -11,10 +12,13 @@ import (
|
||||
|
||||
// Article is the example's stand-in for a content plugin's record.
|
||||
type Article struct {
|
||||
ID string
|
||||
Slug string
|
||||
Title string
|
||||
Published bool
|
||||
ID string
|
||||
Slug string
|
||||
Title string
|
||||
Published bool
|
||||
PublishedAt time.Time
|
||||
Author string
|
||||
Headings []string // the body's level-2 headings
|
||||
}
|
||||
|
||||
// NewsPlugin is an example content plugin whose record type describes its
|
||||
@@ -24,7 +28,8 @@ type NewsPlugin struct{ articles []Article }
|
||||
func (NewsPlugin) ID() string { return "acme.news" }
|
||||
|
||||
// MenuItemTypes registers acme-article with a content descriptor: the
|
||||
// controller that edits articles, their morph type, Describe and Match.
|
||||
// controller that edits articles, their morph type, what kind of record an
|
||||
// article is, the MCP tool that edits one, Describe and Match.
|
||||
func (p NewsPlugin) MenuItemTypes() []pact.MenuItemType {
|
||||
return []pact.MenuItemType{{
|
||||
Key: "acme-article",
|
||||
@@ -35,6 +40,8 @@ func (p NewsPlugin) MenuItemTypes() []pact.MenuItemType {
|
||||
Content: &pact.ContentDescriptor{
|
||||
Controller: "acme.news.articles",
|
||||
Model: "acme_news_articles",
|
||||
Kind: pact.ContentKindArticle,
|
||||
Tools: map[string]string{pact.ContentToolEdit: "acme_update_article"},
|
||||
Describe: p.describe,
|
||||
Match: p.match,
|
||||
},
|
||||
@@ -46,17 +53,28 @@ func (p NewsPlugin) resolve(_ context.Context, in pact.MenuItemResolve) (*pact.R
|
||||
}
|
||||
|
||||
// describe answers one article per locale; the URL is built from the
|
||||
// layered pattern it receives.
|
||||
// layered pattern it receives. Besides the page defaults it reports the
|
||||
// first publication, the author and the body's heading outline, whose
|
||||
// headings start at level 2 because the page renders the title as the level-1
|
||||
// heading.
|
||||
func (p NewsPlugin) describe(_ context.Context, in pact.ContentDescribe) (*pact.ContentRecord, error) {
|
||||
for _, a := range p.articles {
|
||||
if a.ID != in.ID {
|
||||
continue
|
||||
}
|
||||
url := strings.NewReplacer(":slug", a.Slug, ":id", a.ID).Replace(in.Pattern)
|
||||
headings := []pact.ContentHeading{}
|
||||
for _, h := range a.Headings {
|
||||
headings = append(headings, pact.ContentHeading{Level: 2, Text: h})
|
||||
}
|
||||
published := a.PublishedAt
|
||||
return &pact.ContentRecord{
|
||||
ID: a.ID,
|
||||
Published: a.Published,
|
||||
Locales: []pact.ContentLocale{{Locale: "en", URL: url, Title: a.Title}},
|
||||
ID: a.ID,
|
||||
Published: a.Published,
|
||||
PublishedAt: &published,
|
||||
Authors: []pact.ContentPerson{{Name: a.Author}},
|
||||
TopHeading: 2,
|
||||
Locales: []pact.ContentLocale{{Locale: "en", URL: url, Title: a.Title, Headings: headings}},
|
||||
}, nil
|
||||
}
|
||||
return nil, nil
|
||||
@@ -82,7 +100,11 @@ func (p NewsPlugin) match(_ context.Context, in pact.ContentMatch) (*pact.Conten
|
||||
|
||||
func ExampleRegistry_MatchPath() {
|
||||
app := exampleApp() // its menu_items.yaml overrides acme-post of BlogPlugin
|
||||
news := NewsPlugin{articles: []Article{{ID: "7", Slug: "launch", Title: "Launch", Published: true}}}
|
||||
news := NewsPlugin{articles: []Article{{
|
||||
ID: "7", Slug: "launch", Title: "Launch", Published: true,
|
||||
PublishedAt: time.Date(2026, 10, 1, 9, 0, 0, 0, time.UTC),
|
||||
Author: "Ada", Headings: []string{"Why", "How"},
|
||||
}}}
|
||||
plugins := []interface{ ID() string }{BlogPlugin{}, news}
|
||||
if err := signpost.Activate(app, plugins); err != nil {
|
||||
fmt.Println(err)
|
||||
@@ -95,10 +117,16 @@ func ExampleRegistry_MatchPath() {
|
||||
rec, _ := reg.Describe(context.Background(), typ.Key, "7", "")
|
||||
fmt.Println(typ.Key, rec.Locales[0].URL)
|
||||
|
||||
// The descriptor's optional facts reach every consumer as given.
|
||||
fmt.Println(typ.Content.Kind, typ.Content.Tools[pact.ContentToolEdit])
|
||||
fmt.Println(rec.PublishedAt.Format(time.DateOnly), rec.Authors[0].Name, rec.TopHeading, len(rec.Locales[0].Headings))
|
||||
|
||||
// A visitor's path maps back to the record.
|
||||
key, res, _ := reg.MatchPath(context.Background(), "/news/launch", "")
|
||||
fmt.Println(key, res.ID, res.Exact)
|
||||
// Output:
|
||||
// acme-article /news/launch
|
||||
// article acme_update_article
|
||||
// 2026-10-01 Ada 2 2
|
||||
// acme-article 7 true
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user