# 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. - **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"] ``` ### 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: ```sh mysql -N -B --raw < scripts/winter-export-menus.sql > menus.jsonl mysql -N -B --raw < scripts/winter-export-entries.sql > menu-entries.jsonl ``` 2. In the application, after the plugins that register the target types are installed: ```sh ./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.MenuEntry.RequestedMenuID` | The menu the last save named when `BeforeSave` replaced it with the parent's menu; 0 otherwise. | | `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. - Kept entries are made to pass the admin save rules, one report line each: a URL `classes.AllowedURL` refuses is trimmed when only surrounding whitespace is wrong and cleared otherwise; a target other than `_self` or `_blank` is cleared; an entry whose parent is in another menu is copied as a top-level entry of its own menu (with its own children), so it is not left out of the public tree. - 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); the admin form refuses with 422 a parent from another menu than the one the form names (`errors.parent_menu`), and an entry moved to another menu takes its whole subtree along, so no descendant is left with a parent in another menu. An unknown type, a registry type that takes a reference saved without one (`errors.reference_required`; it would resolve to nothing and vanish from the public tree), 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 ```sh 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.