# 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. ```go 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 (`models.MenuEntry.RequestedMenuID` then reports the other menu the save named, if any), 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 missing reference for a type that takes one, 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. - **Site cache.** A menu create, update or delete and a menu-entry create, update, delete or reorder in the admin ask the host site to drop its page caches once the save commits. See [Site cache](#site-cache). - **Public API.** `GET /_menu/api/v1/menus/{code}` serves an active menu with its published entries as a nested tree. See [Usage](#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](#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. ```json { "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`: ```yaml cors: paths: ["_menu/api/*"] allowed_origins: ["https://www.example.com"] allowed_methods: ["GET"] ``` ### Site cache Every committed admin write of the menu screens sends one purge request to the host site, through the framework's site-cache purger in `cooler`: | Save | Purge body | |------|------------| | Menu create, update, delete | `{"kind":"menu","slug":"