- ContentType, ContentTypeForController, Records and MatchPath on the registry; the pure MatchPattern for whole-segment placeholder matching - two types declaring the same Content.Model or Controller stop the boot - TestContent* cover lookups, optional Records, boot errors, MatchPattern and MatchPath precedence (first exact, else first inexact) - docs: Content descriptors in menu-item-types.md, signpost README
185 lines
11 KiB
Markdown
185 lines
11 KiB
Markdown
# signpost
|
|
|
|
Menu item type registry that collects the typed menu item types plugins register at boot, layers their URL patterns with host overrides and resolves menu and sitemap items through the owning plugin.
|
|
|
|
`import "git.golem15.com/golem15/summercms/modules/signpost"`
|
|
|
|
**Name:** A beach signpost points the way to places; the registry tells menus and sitemaps where each item leads.
|
|
|
|
## Overview
|
|
|
|
`signpost` is the typed successor of WinterCMS's `pages.menuitem.listTypes`, `pages.menuitem.getTypeInfo` and `pages.menuitem.resolveItem` events. A content plugin implements `pact.HasMenuItemTypes` and returns `pact.MenuItemType` values: each carries its key, label, default URL pattern, the records an item can point at (`References`) and a `Resolve` function. [party](../party/README.md) calls `signpost.Activate` once at boot, after every Register and before the first Boot, and the registry is published on the application. Menu and sitemap plugins read it with `app.Lookup[*signpost.Registry]()`, offer its types in their admin forms and resolve their items through it. No string event carries the list, the type info or the resolve call.
|
|
|
|
## Role in the framework
|
|
|
|
A small registry between content plugins (which own records and their URLs) and consumer plugins (menus, sitemaps). It depends on [backpack](../backpack/README.md) and [pact](../pact/README.md) only, so the framework never imports a plugin. Its one admin binding is `signpost.ReferenceOptions`, which a controller's `pact.DependentOptionsProvider` returns for a reference dropdown that depends on a type dropdown (see [Forms](../../docs/backend/forms.md#dependent-dropdowns)).
|
|
|
|
Use cases:
|
|
|
|
- Let a blog plugin offer "post", "category" and "all posts" as menu item types without the menu plugin importing it.
|
|
- Build headless URLs from patterns such as `/blog/:slug`, and let the host application change them per type.
|
|
- Fill the reference dropdown of a menu entry or a sitemap row with the records of the chosen type.
|
|
|
|
## Features
|
|
|
|
- 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.
|
|
- `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
|
|
|
|
A content plugin registers its types:
|
|
|
|
```go
|
|
package blog
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
)
|
|
|
|
var _ pact.HasMenuItemTypes = (*Plugin)(nil)
|
|
|
|
type Plugin struct{}
|
|
|
|
func (p *Plugin) MenuItemTypes() []pact.MenuItemType {
|
|
return []pact.MenuItemType{{
|
|
Key: "acme-post",
|
|
Label: "acme.blog::lang.menuitem.post",
|
|
DefaultPattern: "/blog/:slug",
|
|
Placeholders: []string{":slug"},
|
|
References: func(ctx context.Context) ([]pact.MenuItemReference, error) {
|
|
return []pact.MenuItemReference{{Value: "hello-world", Label: "Hello world"}}, nil
|
|
},
|
|
Resolve: func(ctx context.Context, in pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) {
|
|
url := strings.ReplaceAll(in.Pattern, ":slug", in.Reference)
|
|
return &pact.ResolvedMenuItem{
|
|
Title: "Hello world",
|
|
URL: url,
|
|
Alternates: []pact.MenuItemAlternate{{Locale: "en", URL: url, Title: "Hello world"}},
|
|
}, nil
|
|
},
|
|
}, {
|
|
Key: "acme-all-posts",
|
|
Label: "acme.blog::lang.menuitem.all_posts",
|
|
DefaultPattern: "/blog/:slug",
|
|
PatternFrom: "acme-post",
|
|
Nesting: true,
|
|
Resolve: resolveAllPosts,
|
|
}}
|
|
}
|
|
```
|
|
|
|
A menu plugin resolves an entry and serves the reference dropdown of its form:
|
|
|
|
```go
|
|
reg, _ := app.Lookup[*signpost.Registry]()
|
|
item, err := reg.Resolve(ctx, entry.Type, entry.Reference, entry.URLPattern, true, "")
|
|
|
|
func (c entriesController) DependentOptions(ctx context.Context, field string, values map[string]any) (pact.DependentOptionSet, error) {
|
|
typ, _ := values["type"].(string)
|
|
return signpost.ReferenceOptions(ctx, c.registry(), typ)
|
|
}
|
|
```
|
|
|
|
### Content descriptors
|
|
|
|
A record type adds a `pact.ContentDescriptor`: the admin controller that edits the records, their morph type, and a `Describe` that answers each locale's URL (built from the pattern it receives, as `Resolve` builds alternates), title, description, image and word count:
|
|
|
|
```go
|
|
{
|
|
Key: "acme-post",
|
|
DefaultPattern: "/blog/:slug",
|
|
Resolve: resolvePost,
|
|
Content: &pact.ContentDescriptor{
|
|
Controller: "acme.blog.posts",
|
|
Model: "acme_blog_posts",
|
|
Describe: func(ctx context.Context, in pact.ContentDescribe) (*pact.ContentRecord, error) {
|
|
post, err := loadPost(ctx, in.ID)
|
|
if err != nil || post == nil {
|
|
return nil, err
|
|
}
|
|
return &pact.ContentRecord{
|
|
ID: in.ID,
|
|
Published: post.Published,
|
|
Locales: []pact.ContentLocale{{
|
|
Locale: "en",
|
|
URL: strings.ReplaceAll(in.Pattern, ":slug", post.Slug),
|
|
Title: post.Title,
|
|
}},
|
|
}, nil
|
|
},
|
|
},
|
|
}
|
|
```
|
|
|
|
A consumer maps a record event to its type and reads the record's new URL:
|
|
|
|
```go
|
|
app.Events.Listen("acme.audit", func(ctx context.Context, ev *pact.RecordSaved) error {
|
|
reg, _ := app.Lookup[*signpost.Registry]()
|
|
typ, ok := reg.ContentTypeForModel(ev.Model)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
rec, err := reg.Describe(ctx, typ.Key, ev.ID, "")
|
|
if err != nil || rec == nil {
|
|
return err
|
|
}
|
|
log.Printf("%s now lives at %s", ev.ID, rec.Locales[0].URL)
|
|
return nil
|
|
})
|
|
```
|
|
|
|
## API reference
|
|
|
|
| Identifier | Description |
|
|
|------------|-------------|
|
|
| `signpost.Activate` | Collects the `pact.HasMenuItemTypes` types of the activated plugins, reads the host overrides and publishes one `*signpost.Registry`; party calls it after Register and before Boot. |
|
|
| `signpost.Registry` | The read-only registry of menu item types and host pattern overrides. |
|
|
| `signpost.Registry.Types` | Every registered type, in plugin order and then declaration order. |
|
|
| `signpost.Registry.Type` | The type registered under a key. |
|
|
| `signpost.Registry.Pattern` | The layered URL pattern of an item: item override, host override, the record type's host override for a list type, default. |
|
|
| `signpost.ValidItemPattern` | Whether a pattern may serve as an item override: a path starting with a single `/`, without surrounding whitespace or control characters. Plugins use it to refuse such a pattern on save. |
|
|
| `signpost.Registry.Resolve` | Resolves one item through its type with the layered pattern; an unknown type is an error. |
|
|
| `signpost.Registry.ContentTypes` | The types that carry a content descriptor, in registration order. |
|
|
| `signpost.Registry.ContentType` | The type registered under a key when it carries a content descriptor. |
|
|
| `signpost.Registry.ContentTypeForController` | The content type whose descriptor names an admin controller ID. |
|
|
| `signpost.Registry.ContentTypeForModel` | The content type whose descriptor declares a morph type, as record events carry it. |
|
|
| `signpost.Registry.Describe` | Describes one record of a content type through its descriptor with the layered pattern; an unknown type or one without a descriptor is an error, an unknown record answers nil. |
|
|
| `signpost.Registry.Records` | One page of a content type's records after a given ID; an empty page for a type without `Records`. |
|
|
| `signpost.Registry.MatchPath` | The content type and record a public path belongs to: the first exact `Match` in registration order, else the first inexact one, else none. |
|
|
| `signpost.MatchPattern` | Matches a path against a URL pattern by whole segments and returns the placeholder values; a literal segment, the segment count and a non-empty value must all fit. |
|
|
| `signpost.ReferenceOptions` | The dependent dropdown options of a type's references, flattened with their depth; `Applies` false for a type without references. |
|
|
| `signpost.ConfigKey` | `menu_items.patterns`, the host configuration key of the pattern overrides. |
|
|
|
|
## Configuration
|
|
|
|
The host application overrides a type's URL pattern in `config/menu_items.yaml`:
|
|
|
|
```yaml
|
|
patterns:
|
|
acme-post: /news/:slug
|
|
acme-category: /:slug
|
|
```
|
|
|
|
`menu_items.patterns` is a map from type key to pattern, read once at boot. Type keys contain dashes, so the map can only be set in a YAML file: there is no `SUMMER_` environment override for it. An override for a type no plugin registers, or one that does not start with `/`, fails the boot.
|
|
|
|
## Dependencies
|
|
|
|
- SummerCMS modules: [backpack](../backpack/README.md), [pact](../pact/README.md).
|
|
- Third-party: none.
|
|
- Standard library: `context`, `fmt`, `strings`.
|
|
|
|
## Testing
|
|
|
|
```sh
|
|
go test ./modules/signpost/...
|
|
```
|
|
|
|
The tests use an in-memory configuration and need no external services.
|