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:
Jakub Zych
2026-11-04 08:31:49 +01:00
parent 399efed845
commit 561a56c768
6 changed files with 382 additions and 17 deletions

View File

@@ -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
}