- GET /_menu/api/v1/menus/{code}?locale=xx, anonymous, menu-public-api bucket (120/min per client IP)
- classes.BuildTree: published entries nested by parent, sort_order then id, scheme-checked URLs
- registry entries resolved through signpost with url_pattern as the item override and the locale's alternate
- list types keep stored URL and title, generated children follow stored ones; unresolvable entries left out
- TestMenuAPISmoke; README with API, CORS paths, import workflow and Winter adaptations
- go mod tidy records the test dependencies
13 KiB
golem15.menu
The compiled menu plugin for SummerCMS applications: site menus whose entries are links, dividers and items of the framework's menu item type registry, edited in the admin as a tree per menu and served to frontends as a published JSON tree.
import _ "git.golem15.com/golem15/sm-menu-plugin"
Module git.golem15.com/golem15/sm-menu-plugin, package menu, plugin ID golem15.menu.
Overview
The plugin is a compiled SummerCMS plugin, a port of the WinterCMS Golem15.Menu plugin. Its init registers it with the framework, so an application loads it by listing it in summer.yaml; summer build then emits the blank import above into the application's plugins.gen.go.
It requires no other plugin. Content types (blog posts, categories, pages) come from the framework's menu item type registry (signpost): any plugin that registers menu item types offers them in the entry form and resolves them in the API, without the menu plugin importing it. MorphName is the PHP class string (Golem15\Menu\Models\Menu, Golem15\Menu\Models\MenuEntry).
Features
- Tables.
golem15_menu_menus(id, name, code unique, is_active default true, css_class, timestamps) andgolem15_menu_menu_entries(id, menu_id, parent_id, nest_left, nest_right, nest_depth, sort_order, type, title, url, reference, code, icon, css_class, target, is_published default true, timestamps) keep the PHP columns. One additive nullable column,url_pattern, holds the per-entry URL pattern override of a registry entry. Three gormigrate steps create them; AutoMigrate is not used. - Save rules.
models.MenuEntry.BeforeSaveports the PHPbeforeSave: the nest columns become 0 when unset, an entry withoutsort_ordergoes after the menu's last entry, an entry with a parent always takes the parent's menu, a missing title is stored empty and a missing published flag is true. The nest columns are kept for the PHP schema; the tree isparent_idplussort_order. - Entry types. The built-ins
url(Link,models.TypeURL) anddivider(Divider,models.TypeDivider), then every registry type with its owning plugin's label. The reference dropdown depends on the type throughsignpost.ReferenceOptions; built-ins take no reference. The admin save refuses an unknown type, a reference the type does not offer, a URL outside the scheme allowlist and a target other than_self,_blankor empty. - URL allowlist.
classes.AllowedURLaccepts an empty URL, a path starting with/, a fragment starting with#, andhttp:,https:,mailto:andtel:URLs. Anything else (javascript:,data:, a URL with surrounding whitespace or control characters) is a 422errors.url_schemeon save and anullURL in the API. - Admin. A top-level Menus item (icon
list, order 510) with the side items Menus and Menu entries, both behind the permissiongolem15.menu.manage_menu(developer and publisher roles). Menus: name, code (preset from name), css_class, is_active. Menu entries: a list of every entry with a menu filter and the toolbar buttons reorder, create and delete; a form with menu, parent, type, title, url, reference, url_pattern, code, icon, css_class, target and is_published; a tree reorder page scoped to one menu (config_reorder.yaml, partitionmenu_id), which refuses a payload naming an entry of another menu with 409. - Public API.
GET /_menu/api/v1/menus/{code}serves an active menu with its published entries as a nested tree. See Usage. - Import.
menu:import-wintercopies a WinterCMS site's menus and entries from the JSON Lines the export SQL files produce. See CLI commands. - Phrasebooks.
lang/en/lang.yamlandlang/pl/lang.yamlkeep the PHP key names.
Usage
Public menu API
GET /_menu/api/v1/menus/{code}?locale=en
Anonymous; the route group never attaches the backend middleware. code is the menu code (letters, digits, - and _). locale is optional and picks the matching alternate of resolved registry items.
{
"data": {
"code": "main",
"name": "Main",
"css_class": "navbar",
"items": [
{
"id": 1,
"title": "Home",
"type": "url",
"url": "/",
"target": "_self",
"css_class": null,
"icon": null,
"code": "home",
"items": []
},
{
"id": 4,
"title": "Topics",
"type": "acme-all-categories",
"url": "/topics",
"target": null,
"css_class": "dropdown",
"icon": null,
"code": null,
"items": [
{
"id": 5,
"title": "Archive",
"type": "url",
"url": "https://archive.example.com",
"target": "_blank",
"css_class": null,
"icon": null,
"code": null,
"items": []
},
{
"id": null,
"title": "News",
"type": "acme-all-categories",
"url": "/en/news",
"target": null,
"css_class": null,
"icon": null,
"code": null,
"items": []
}
]
},
{
"id": 6,
"title": "",
"type": "divider",
"url": null,
"target": null,
"css_class": null,
"icon": null,
"code": null,
"items": []
}
]
}
}
Rules:
- Only an active menu is served; an unknown, inactive or malformed code is
404 {"error":"Menu not found"}. - Only published entries are served. An unpublished entry hides its whole subtree. (The PHP component did not filter
is_published; the API does, as a deliberate change.) - Within each parent, entries are ordered by
sort_order, thenid. idis the entry id;nullfor a child a registry type generated.typeis the stored type; a generated child carries its parent's registry type.urlentries serve their stored URL whenclasses.AllowedURLaccepts it, elsenull. Dividers servenull.- Registry entries are resolved at request time through the registry with the entry's reference, its
url_patternas the item override (else the host's pattern, else the type's default) and the type'sNesting:- a single type (the resolved item has a URL) serves the URL of the alternate whose locale is
?locale, else the resolved URL; the title is the stored title when not empty, else the resolved title for that locale; - a list type (the resolved item has no URL) keeps the entry's stored URL (scheme-checked) and title;
- resolved sub-items follow the entry's stored children in
items, each with the alternate's title and URL for?localewhen there is one; theirtarget,css_class,iconandcodearenull.
- a single type (the resolved item has a URL) serves the URL of the alternate whose locale is
- An entry whose type no plugin registers, or whose reference no longer resolves, is left out with its subtree and logged.
targetis_self,_blankornull;css_class,iconandcodearenullwhen empty.itemsis always an array.
The tree is built by classes.BuildTree from the entries api.Show loads.
Throttling and CORS
The API is throttled by the surf bucket menu-public-api (menu.BucketPublicAPI): 120 requests a minute per client IP, read behind the host's trusted proxies. The 429 body is surf's {"message":"Too Many Attempts."}.
The plugin sends no CORS headers. A host whose frontend runs on another origin adds the path to http.cors.paths in config/http.yaml:
cors:
paths: ["_menu/api/*"]
allowed_origins: ["https://www.example.com"]
allowed_methods: ["GET"]
Winter import
-
On the WinterCMS database, check the table names and entry types named in the headers of
scripts/winter-export-menus.sqlandscripts/winter-export-entries.sql, then run both read-only exports:mysql -N -B --raw <database> < scripts/winter-export-menus.sql > menus.jsonl mysql -N -B --raw <database> < scripts/winter-export-entries.sql > menu-entries.jsonl -
In the application, after the plugins that register the target types are installed:
./bin/acme menu:import-winter --menus menus.jsonl --entries menu-entries.jsonl --dry-run ./bin/acme menu:import-winter --menus menus.jsonl --entries menu-entries.jsonl
API reference
| Identifier | Description |
|---|---|
menu.Plugin |
The golem15.menu plugin: migrations, models, phrasebooks, admin controllers, permissions, navigation, the menu:import-winter command, the public route and its throttle bucket. |
menu.BucketPublicAPI |
menu-public-api, the throttle bucket of the public API. |
models.Menu |
A menu on golem15_menu_menus; rules require a name and a unique code. |
models.MenuEntry |
An entry on golem15_menu_menu_entries with the PHP save rules (BeforeSave) and the menu filter scope. |
models.TypeURL, models.TypeDivider |
The built-in entry types url and divider. |
models.Builtin |
Reports whether a type is a built-in. |
models.Text |
The value of an optional text column, "" when null. |
classes.AllowedURL |
The URL scheme allowlist used on save and in the API. |
classes.AllowedTarget |
Accepts _self, _blank or empty (classes.TargetSelf, classes.TargetBlank). |
classes.BuildTree |
Builds the public tree from a menu's published entries (classes.TreeOptions: registry, locale, logger). |
classes.Item |
One item of the public tree. |
classes.Resolver |
The registry methods classes.BuildTree reads; *signpost.Registry implements it. |
api.Show |
The handler of GET /_menu/api/v1/menus/{code}. |
api.Menu |
The data object of the public API. |
console.ImportWinter |
The menu:import-winter command. |
console.ImportWinterFiles |
The import itself, for tests and other callers (console.WinterOptions, console.WinterReport). |
console.DefaultTypeMap |
The Winter type map: link to url, blog_categories to all-journal-categories. |
controllers.MenusControllerID, controllers.MenuEntriesControllerID |
The admin controller ids golem15.menu.menus and golem15.menu.menuentries. |
controllers.PermissionManageMenu |
The permission golem15.menu.manage_menu. |
CLI commands
menu:import-winter --menus FILE --entries FILE [--map old=new ...] [--force] [--dry-run]
- Reads one JSON object per line from each file. Ids,
menu_id,parent_id, the nest columns andsort_orderare kept; both id sequences are moved past the copied ids withsetval. - Types are mapped by
console.DefaultTypeMap(linktourl,blog_categoriestoall-journal-categories); repeatable--map old=newadds or overrides a mapping, and--map old=drops the type. - Dropped with their subtree, one report line each:
pageentries (Winter.Pages is not ported), entries whose type is neither built-in, registered nor mapped, and registry entries whose reference is not among the type's current references. Every remapped entry is reported too. - Tables that already hold rows are refused unless
--force, which replaces them in the same transaction.--dry-runprints the report and writes nothing. An empty file writes nothing and reports zero.
Winter adaptations
- Navigation: PHP hung Menus and Menu entries on another plugin's SiteManager side menu, which is not ported; the plugin registers its own top-level Menus item.
- Menu entries list: PHP listed the top-level entries of one menu chosen in a toolbar select, with a Children tab and partial in the form. Here the list shows every entry, a menu filter picks the menu, and the reorder page edits the tree per menu; the Children tab is not ported.
- Entry form: the menu field is a relation field (also the reorder partition);
url_pattern(per-entry pattern override) andtarget(_self,_blank) are new fields. The labels "Menuses" and "Is_published" read Menus and Published. - Type keys: PHP
linkisurl; PHPblog_categoriesis the registry typeall-journal-categorieswhen a blog plugin registers it;pageis not supported. - Saves: an entry with a parent always takes the parent's menu (PHP only when the menu was empty); an unknown type, a parent cycle, a disallowed URL or target and an entry without a menu are refused with 422; a menu code another menu uses is a 422.
- API: the public JSON API replaces the PHP component and filters unpublished entries.
- Timestamps are
TIMESTAMPTZ.
Dependencies
- SummerCMS modules: backpack, bonfire, cabana, lagoon, pact, party, signpost, surf, wire.
- Compiled plugins: none.
- Third-party:
gorm.io/gorm,github.com/go-gormigrate/gormigrate/v2. Test-only:github.com/testcontainers/testcontainers-gowith its Postgres module,github.com/jackc/pgx/v5.
Testing
go vet ./...
go test -short ./... # no Docker
go test ./... # TestMenuAdminSmoke, TestMenuImportSmoke, TestMenuAPISmoke on a Postgres container
The smoke tests register a fake content plugin with the types acme-page (with references), acme-list (a list type) and all-journal-categories, so the plugin needs no content plugin to test.