- 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
133 lines
4.5 KiB
Go
133 lines
4.5 KiB
Go
package signpost_test
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
"git.golem15.com/golem15/summercms/modules/signpost"
|
|
)
|
|
|
|
// Article is the example's stand-in for a content plugin's record.
|
|
type Article struct {
|
|
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
|
|
// articles to consumers such as sitemaps.
|
|
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, 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",
|
|
Label: "acme.news::lang.menuitem.article",
|
|
DefaultPattern: "/news/:slug",
|
|
Placeholders: []string{":slug", ":id"},
|
|
Resolve: p.resolve,
|
|
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,
|
|
},
|
|
}}
|
|
}
|
|
|
|
func (p NewsPlugin) resolve(_ context.Context, in pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
// describe answers one article per locale; the URL is built from the
|
|
// 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,
|
|
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
|
|
}
|
|
|
|
// match reads the path's values with signpost.MatchPattern and checks every
|
|
// one against the record: a slug finds the article, and an :id that names
|
|
// another article makes the match inexact. Drafts never match.
|
|
func (p NewsPlugin) match(_ context.Context, in pact.ContentMatch) (*pact.ContentMatchResult, error) {
|
|
values, ok := signpost.MatchPattern(in.Pattern, in.Path)
|
|
if !ok {
|
|
return nil, nil
|
|
}
|
|
for _, a := range p.articles {
|
|
if a.Slug != values["slug"] || !a.Published {
|
|
continue
|
|
}
|
|
id, hasID := values["id"]
|
|
return &pact.ContentMatchResult{ID: a.ID, Locale: "en", Exact: !hasID || id == a.ID}, nil
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
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,
|
|
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)
|
|
return
|
|
}
|
|
reg, _ := app.Lookup[*signpost.Registry]()
|
|
|
|
// A record event names the model; the registry finds its type.
|
|
typ, _ := reg.ContentTypeForModel("acme_news_articles")
|
|
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
|
|
}
|