Files
sm-menu-plugin/README.md
Jakub Zych 33a65ad802 feat(17-04): public menu API with registry resolution, throttle bucket and README
- 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
2026-10-12 17:51:27 +02:00

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) and golem15_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.BeforeSave ports the PHP beforeSave: the nest columns become 0 when unset, an entry without sort_order goes 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 is parent_id plus sort_order.
  • Entry types. The built-ins url (Link, models.TypeURL) and divider (Divider, models.TypeDivider), then every registry type with its owning plugin's label. The reference dropdown depends on the type through signpost.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, _blank or empty.
  • URL allowlist. classes.AllowedURL accepts an empty URL, a path starting with /, a fragment starting with #, and http:, https:, mailto: and tel: URLs. Anything else (javascript:, data:, a URL with surrounding whitespace or control characters) is a 422 errors.url_scheme on save and a null URL in the API.
  • Admin. A top-level Menus item (icon list, order 510) with the side items Menus and Menu entries, both behind the permission golem15.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, partition menu_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-winter copies a WinterCMS site's menus and entries from the JSON Lines the export SQL files produce. See CLI commands.
  • Phrasebooks. lang/en/lang.yaml and lang/pl/lang.yaml keep 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, then id.
  • id is the entry id; null for a child a registry type generated. type is the stored type; a generated child carries its parent's registry type.
  • url entries serve their stored URL when classes.AllowedURL accepts it, else null. Dividers serve null.
  • Registry entries are resolved at request time through the registry with the entry's reference, its url_pattern as the item override (else the host's pattern, else the type's default) and the type's Nesting:
    • 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 ?locale when there is one; their target, css_class, icon and code are null.
  • An entry whose type no plugin registers, or whose reference no longer resolves, is left out with its subtree and logged.
  • target is _self, _blank or null; css_class, icon and code are null when empty. items is 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

  1. On the WinterCMS database, check the table names and entry types named in the headers of scripts/winter-export-menus.sql and scripts/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
    
  2. 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 and sort_order are kept; both id sequences are moved past the copied ids with setval.
  • Types are mapped by console.DefaultTypeMap (link to url, blog_categories to all-journal-categories); repeatable --map old=new adds or overrides a mapping, and --map old= drops the type.
  • Dropped with their subtree, one report line each: page entries (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-run prints 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) and target (_self, _blank) are new fields. The labels "Menuses" and "Is_published" read Menus and Published.
  • Type keys: PHP link is url; PHP blog_categories is the registry type all-journal-categories when a blog plugin registers it; page is 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-go with 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.