From 561a56c768fb1ff09f32145f4a50f299f01821eb Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 4 Nov 2026 08:31:49 +0100 Subject: [PATCH] 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 --- docs/plugins/menu-item-types.md | 33 ++- modules/pact/README.md | 14 +- modules/pact/content.go | 101 ++++++++- modules/signpost/README.md | 4 +- modules/signpost/content_fields_smoke_test.go | 199 ++++++++++++++++++ modules/signpost/example_content_test.go | 48 ++++- 6 files changed, 382 insertions(+), 17 deletions(-) create mode 100644 modules/signpost/content_fields_smoke_test.go diff --git a/docs/plugins/menu-item-types.md b/docs/plugins/menu-item-types.md index 297faf6b..fc80dbc7 100644 --- a/docs/plugins/menu-item-types.md +++ b/docs/plugins/menu-item-types.md @@ -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 ``` diff --git a/modules/pact/README.md b/modules/pact/README.md index 7ae0777e..5ca4cd38 100644 --- a/modules/pact/README.md +++ b/modules/pact/README.md @@ -36,6 +36,7 @@ Use cases: - Optional admin hooks a controller or model can implement: list and form query scoping (`pact.ListExtendQuery`, `pact.FormExtendQuery`), list row states (`pact.ListRowStates` with the fixed `pact.RowState` set `pact.RowStateDeleted`, `pact.RowStateNegative` and `pact.RowStateDisabled`), values of unstored list columns filled once per page (`pact.ListExtendRecords`), the record title of the form and preview screens (`pact.FormRecordTitle`), create, update and delete hooks (`pact.FormBeforeCreate`, `pact.FormAfterUpdate`, `pact.FormBeforeDelete` and their siblings), a hook after a reorder save (`pact.ReorderAfterSave`), form-only fields that reach those hooks without being model columns (`pact.FormVirtualFields`), validation rules per operation for admin saves (`pact.FormRules`), relation hooks (`pact.RelationExtendManageQuery`, `pact.RelationExtendOptionsQuery`, `pact.RelationBeforeLink`), relation child hooks around creating, updating and deleting a related record (`pact.RelationBeforeCreate`, `pact.RelationAfterCreate`, `pact.RelationBeforeUpdate`, `pact.RelationAfterUpdate`, `pact.RelationBeforeDelete`, `pact.RelationAfterDelete`), filter scopes (`pact.FilterScope`, `pact.FilterOptions`) and dropdown options (`pact.DropdownOptionsProvider`). - Dependent dropdown options: `pact.DependentOptionsProvider` answers a `pact.DependentOptionSet` (whether the sibling's value takes a choice at all, and the choices) for a dropdown with `dependsOn`, from the submitted values of its siblings and with a context, so the choices can come from the database; the admin framework calls it for the options route and again on every save. `pact.Option.Depth` indents a choice taken from a tree. - Menu item types: `pact.HasMenuItemTypes` returns `pact.MenuItemType` values (key, label, default URL pattern, `PatternFrom` for a list type, the `References` an item can point at as a `pact.MenuItemReference` tree, and `Resolve`), the typed successor of WinterCMS's `pages.menuitem.*` events. `Resolve` receives a `pact.MenuItemResolve` with the pattern already layered and answers a `pact.ResolvedMenuItem` with its alternates as an ordered `[]pact.MenuItemAlternate`. [signpost](../signpost/README.md) collects them at boot. A record type may set `Content` to a `pact.ContentDescriptor`, which describes its records for consumers that work on every content type at once (sitemaps, search indexers, metadata editors): the admin controller that edits them, their morph type, `Describe` (a `pact.ContentRecord` with per-locale `pact.ContentLocale` URL, title, description, image and word count), and the optional `Records` (paged through `pact.ContentList` and `pact.ContentPage`) and `Match` (a public path to a `pact.ContentMatchResult`). +- Content facts: a descriptor may also say what kind of record the type holds (`pact.ContentDescriptor.Kind`) and which MCP tools change its records, by operation (`pact.ContentDescriptor.Tools`); `Describe` and `Records` may report a record's first publication, ordered parents, authors and top heading level, and per locale its images, videos and heading outline. All of them are optional. - Record events: `pact.RecordSaved` and `pact.RecordDeleted` are fired as pointers on the application's event bus after a record write commits, with the controller, the morph type, the id, the request locale and, for a save, the `pact.RecordChange` list of changed attributes with old and new values per locale. The admin framework fires them for every admin write and `cabana.EmitRecordSaved` lets plugin code that writes directly fire the same events. - Singleton admin controllers: `pact.AdminSingleton` (`SingletonRecord`, first or create) makes a controller edit exactly one record, opened directly instead of from a list. - A background job contract (`pact.Job`, `pact.JobArgs`) that does not depend on any queue library. @@ -213,12 +214,19 @@ func (p *Plugin) Schedule() []pact.ScheduledCommand { | `pact.MenuItemResolve` | What `Resolve` receives: type, reference, the layered URL pattern, whether to expand nested items and the unprefixed locale. | | `pact.ResolvedMenuItem` | A resolved item: title, URL (empty for a pure list type), modification time, ordered alternates and nested items. | | `pact.MenuItemAlternate` | The URL and title of a resolved item in one locale. | -| `pact.ContentDescriptor` | The records behind a menu item type: `Controller` (the admin controller ID that edits them), `Model` (their morph type, required), `Describe` (required), and the optional `Records` and `Match`. The funcs read through `cabana.TxFromContext` when the context carries a write transaction. | +| `pact.ContentDescriptor` | The records behind a menu item type: `Controller` (the admin controller ID that edits them), `Model` (their morph type, required), `Describe` (required), the optional `Records` and `Match`, `Kind` (one of the kind constants below, "" when unsaid) and `Tools` (operation to full MCP tool name). The funcs read through `cabana.TxFromContext` when the context carries a write transaction. | +| `pact.ContentKindArticle`, `pact.ContentKindCollection`, `pact.ContentKindProfile`, `pact.ContentKindPage` | The values of `ContentDescriptor.Kind`: a dated piece with authors, a record that lists others, a person or organisation, a standalone page. | +| `pact.ContentToolEdit`, `pact.ContentToolHeadings`, `pact.ContentToolImageAlt` | The keys of `ContentDescriptor.Tools`: edit a record, change its body's headings, set its images' alternative text. | | `pact.ContentList` | What `Records` receives: the layered pattern, `After` (the last ID of the previous page), `Limit` and the unprefixed locale. | | `pact.ContentPage` | One page of `Records`, and `Next`, the `After` of the following page ("" on the last). | | `pact.ContentDescribe` | What `Describe` receives: the record `ID`, the layered pattern and the unprefixed locale. | -| `pact.ContentRecord` | A described record: `ID`, `Published`, `LastMod` and its `Locales`. | -| `pact.ContentLocale` | A record in one locale: `URL` ("" without a public page), `Title`, `Description`, `Image` and `Words`. | +| `pact.ContentRecord` | A described record: `ID`, `Published`, `LastMod` and its `Locales`, and optionally `PublishedAt` (first publication), `Parents` (ordered, the first is the default primary), `Authors` and `TopHeading` (the highest heading level the body may use, 0 for no rule). | +| `pact.ContentLocale` | A record in one locale: `URL` ("" without a public page), `Title`, `Description`, `Image` and `Words`, and optionally `Images`, `Videos` and `Headings`. | +| `pact.ContentRef` | Another record: a menu item type key (`Type`) and the record `ID`. | +| `pact.ContentPerson` | A person named on a record: `Name` and a `Ref` to their own record (`Type` "" when they have none). | +| `pact.ContentImage` | An image a record shows: `URL`, `Alt`, `Decorative` (no content, empty `Alt` intended), `Width` and `Height` (0 when unknown). | +| `pact.ContentVideo` | A video a record embeds: `Provider` and the video `ID` there. | +| `pact.ContentHeading` | One heading of a record's body: `Level` (1 to 6) and `Text`. | | `pact.ContentMatch` | What `Match` receives: a public `Path`, the layered pattern and the unprefixed locale. | | `pact.ContentMatchResult` | `Match`'s answer: the record `ID`, the `Locale` the path addresses and `Exact` (false when a segment does not match the record). | | `pact.RecordSaved` | Event fired as a pointer after a record create or update commits: `Controller` ("" outside the admin forms), `Model` (morph type), `ID`, `Locale`, `Created` and `Changes`. | diff --git a/modules/pact/content.go b/modules/pact/content.go index 61607e33..d29a9592 100644 --- a/modules/pact/content.go +++ b/modules/pact/content.go @@ -21,14 +21,52 @@ import ( // The functions read through cabana.TxFromContext(ctx) when ctx carries a // write transaction, so code running inside a save (a form extension's Save, // for example) sees the values the save has just written. +// +// Kind and Tools are optional facts about the type as a whole: what kind of +// thing a record is (one of the ContentKind constants, "" when the type does +// not say) and which MCP tools change its records, keyed by operation (the +// ContentTool constants), so a consumer can point at a fixing tool without +// importing the content plugin. type ContentDescriptor struct { Controller string Model string Records func(ctx context.Context, in ContentList) (ContentPage, error) `json:"-"` Describe func(ctx context.Context, in ContentDescribe) (*ContentRecord, error) `json:"-"` Match func(ctx context.Context, in ContentMatch) (*ContentMatchResult, error) `json:"-"` + // Kind is what a record of the type is, one of the ContentKind constants; + // "" when the type does not say. + Kind string + // Tools maps an operation (a ContentTool constant) to the full name of + // the MCP tool that performs it on a record, such as "acme_update_page"; + // nil when the type names none. + Tools map[string]string } +// The kinds a ContentDescriptor.Kind names. +const ( + // ContentKindArticle is a dated piece with authors, such as a post. + ContentKindArticle = "article" + // ContentKindCollection is a record that lists other records, such as a + // category or a tag. + ContentKindCollection = "collection" + // ContentKindProfile is a record about a person or an organisation. + ContentKindProfile = "profile" + // ContentKindPage is a standalone page. + ContentKindPage = "page" +) + +// The operations a ContentDescriptor.Tools map is keyed by. +const ( + // ContentToolEdit names the tool that edits a record's fields and body. + ContentToolEdit = "edit" + // ContentToolHeadings names the tool that changes the headings of a + // record's body. + ContentToolHeadings = "headings" + // ContentToolImageAlt names the tool that sets the alternative text of + // the images in a record's body. + ContentToolImageAlt = "image_alt" +) + // ContentList is what ContentDescriptor.Records receives. Pattern is the URL // pattern already layered by the registry (the host's override, else the // type's default). After is the ID of the last record of the previous page, @@ -60,17 +98,32 @@ type ContentDescribe struct { // ContentRecord describes one record. ID is its primary key as text, // Published whether visitors can see it, LastMod its last change (nil when // unknown). Locales lists the record per locale in the site's locale order. +// The remaining fields are optional; a nil slice and an empty one mean the +// same: the type has nothing to say. type ContentRecord struct { ID string Published bool LastMod *time.Time Locales []ContentLocale + // PublishedAt is the record's first publication, nil when unknown or + // never published. + PublishedAt *time.Time + // Parents lists the records this one sits under, in order of preference: + // the first is the default primary parent. + Parents []ContentRef + // Authors lists the people who wrote the record, in byline order. + Authors []ContentPerson + // TopHeading is the highest heading level the record's body may use: 2 + // when the page renders the title as its only level-1 heading, 0 when the + // type sets no rule. + TopHeading int } // ContentLocale is a record in one locale. URL is its public path, "" when // the locale or the host has no public page for it. Title, Description and // Image (a URL) are the record's own defaults for page metadata; Words is the -// body's word count, nil when the type has no body. +// body's word count, nil when the type has no body. Images, Videos and +// Headings are optional; a nil slice and an empty one mean the same. type ContentLocale struct { Locale string URL string @@ -78,6 +131,52 @@ type ContentLocale struct { Description string Image string Words *int + // Images lists the record's images in this locale (the featured image + // first, then the body's in document order). + Images []ContentImage + // Videos lists the videos the body embeds, in document order. + Videos []ContentVideo + // Headings is the body's heading outline in document order. + Headings []ContentHeading +} + +// ContentRef points at another record: Type is a menu item type key and ID +// the record's ID within it. +type ContentRef struct { + Type string + ID string +} + +// ContentPerson is a person named on a record. Ref points at the person's +// own record; its Type is "" when the person has none. +type ContentPerson struct { + Name string + Ref ContentRef +} + +// ContentImage is an image a record shows. URL is its address and Alt its +// alternative text; Decorative marks an image that carries no content, whose +// empty Alt is intended. Width and Height are in pixels, 0 when unknown. +type ContentImage struct { + URL string + Alt string + Decorative bool + Width int + Height int +} + +// ContentVideo is a video a record embeds: Provider names the host (such as +// "youtube") and ID the video there. +type ContentVideo struct { + Provider string + ID string +} + +// ContentHeading is one heading of a record's body: Level 1 to 6 and its +// text. +type ContentHeading struct { + Level int + Text string } // ContentMatch is what ContentDescriptor.Match receives: a public path (no diff --git a/modules/signpost/README.md b/modules/signpost/README.md index 336bb283..77a239e3 100644 --- a/modules/signpost/README.md +++ b/modules/signpost/README.md @@ -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 diff --git a/modules/signpost/content_fields_smoke_test.go b/modules/signpost/content_fields_smoke_test.go new file mode 100644 index 00000000..c3c1b0ee --- /dev/null +++ b/modules/signpost/content_fields_smoke_test.go @@ -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) + } + }) +} diff --git a/modules/signpost/example_content_test.go b/modules/signpost/example_content_test.go index 2eb9e7d5..42a557d9 100644 --- a/modules/signpost/example_content_test.go +++ b/modules/signpost/example_content_test.go @@ -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 }