An entry of a registry type with References saved without a reference passed the dependent recheck (null is always among the options), then resolved to nothing and vanished from the public tree with its children. The admin save now refuses it (errors.reference_required).
210 lines
14 KiB
Markdown
210 lines
14 KiB
Markdown
# 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 <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:
|
|
|
|
```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.
|
|
- 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.
|