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:
@@ -131,7 +131,8 @@ Menus point at single records. Consumers such as sitemaps, search indexers and m
|
||||
|
||||
```go src=modules/signpost/example_content_test.go#NewsPlugin.MenuItemTypes
|
||||
// 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",
|
||||
@@ -142,6 +143,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,
|
||||
},
|
||||
@@ -155,6 +158,22 @@ The descriptor names:
|
||||
- `Describe` (required): one record by ID as a `pact.ContentRecord`, drafts included. `Published` tells whether visitors see it and `LastMod` is its last change. `Locales` lists the record per locale in the site's locale order, each with its `URL` (built from the pattern the function receives, exactly as `Resolve` builds alternates), `Title`, `Description`, `Image` and the body's `Words`. A type whose host has no public page for it, such as tags on a site without tag pages, answers an empty `URL`.
|
||||
- `Records` (optional): the records in pages, in a stable order. It receives a `pact.ContentList` with `After`, the last ID of the previous page, and `Limit`, and answers a `pact.ContentPage` whose `Next` is the following page's `After`, "" on the last page. `signpost.Registry.Records` answers an empty page for a type without it.
|
||||
- `Match` (optional): the record a public path belongs to, as a `pact.ContentMatchResult` with its ID and the locale the path addresses. It answers nil when the path names no record visitors can see.
|
||||
- `Kind` (optional): what a record of the type is, one of `pact.ContentKindArticle` (a dated piece with authors), `pact.ContentKindCollection` (a record that lists others, such as a category), `pact.ContentKindProfile` (a person or an organisation) and `pact.ContentKindPage` (a standalone page); "" when the type does not say.
|
||||
- `Tools` (optional): the MCP tools that change the type's records, keyed by operation: `pact.ContentToolEdit` (fields and body), `pact.ContentToolHeadings` (the body's headings) and `pact.ContentToolImageAlt` (the alternative text of the body's images). Each value is a full tool name such as `acme_update_article`, so a consumer that finds a problem in a record can name the tool that fixes it without importing the content plugin.
|
||||
|
||||
`Describe` and `Records` may also report facts that feeds, search indexers and audit tools read. All of them are optional, and a nil slice means the same as an empty one:
|
||||
|
||||
| Field | Meaning |
|
||||
|-------|---------|
|
||||
| `pact.ContentRecord.PublishedAt` | The first publication, nil when unknown or never published (`LastMod` stays the last change). |
|
||||
| `pact.ContentRecord.Parents` | The records this one sits under as `pact.ContentRef` values (a menu item type key and a record ID), in order of preference: the first is the default primary parent. |
|
||||
| `pact.ContentRecord.Authors` | The people who wrote the record, in byline order, as `pact.ContentPerson` values: a `Name` and a `Ref` to the person's own record, whose `Type` is "" when the person has none. |
|
||||
| `pact.ContentRecord.TopHeading` | The highest heading level the body may use: 2 when the page renders the title as its only level-1 heading, 0 when the type sets no rule. |
|
||||
| `pact.ContentLocale.Images` | The record's images in that locale (the featured image first, then the body's in document order) as `pact.ContentImage` values: `URL`, `Alt`, `Decorative` (an image without content, whose empty `Alt` is intended), `Width` and `Height` in pixels (0 when unknown). |
|
||||
| `pact.ContentLocale.Videos` | The videos the body embeds, as `pact.ContentVideo` values: a `Provider` such as `youtube` and the video's `ID` there. |
|
||||
| `pact.ContentLocale.Headings` | The body's heading outline in document order, as `pact.ContentHeading` values with a `Level` (1 to 6) and the `Text`. |
|
||||
|
||||
The registry hands these values through as the type answers them; a type that sets none of them describes, lists and matches exactly as before.
|
||||
|
||||
A content type reads a path's placeholder values with `signpost.MatchPattern`, which matches whole segments only: a literal segment must be equal, the segment count must be equal and a placeholder never matches an empty segment. Every value must then fit the record. When the path names a record but a segment does not fit it (another category, a stale locale prefix, the slug of another locale), the result's `Exact` is false, so the caller can redirect to the record's own URL instead of answering a duplicate page:
|
||||
|
||||
@@ -182,7 +201,11 @@ The registry layers the pattern for every call, like `Resolve`. `signpost.Regist
|
||||
|
||||
```go src=modules/signpost/example_content_test.go#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)
|
||||
@@ -195,11 +218,17 @@ 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
|
||||
```
|
||||
|
||||
|
||||
Reference in New Issue
Block a user