# 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":"