Files
summercms/modules/signpost/README.md
Jakub Zych 0cdff4c258 feat(17-01): menu item type registry and dependent dropdown binding
- pact: HasMenuItemTypes, MenuItemType, MenuItemReference, MenuItemResolve,
  ResolvedMenuItem, MenuItemAlternate, DependentOptionsProvider,
  DependentOptionSet; Option gains Depth (omitempty)
- signpost (new module): boot-time registry with fail-loud key and pattern
  checks, layered URL patterns incl. PatternFrom for list types, host
  overrides at menu_items.patterns, Resolve and ReferenceOptions
- party: signpost.Activate after postcard, before Boot
- cabana: dependsOn on dropdowns (boot checks, controller-only), GET
  fields/{field}/dependent-options with swag, inventories, conformance case
- companions fixture, TestSignpostSmoke, TestCompanionsDependentSmoke, docs
2026-10-12 15:09:14 +02:00

6.7 KiB

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 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 and pact 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).

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.
  • 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.
  • 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:

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:

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

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.Registry.Resolve Resolves one item through its type with the layered pattern; an unknown type is an error.
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:

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, pact.
  • Third-party: none.
  • Standard library: context, fmt, strings.

Testing

go test ./modules/signpost/...

The tests use an in-memory configuration and need no external services.