The importer copied URLs, targets and cross-menu parents as exported, so a javascript: URL or a _parent target broke the first admin save of the row, and an entry under a parent in another menu vanished from the public tree. Kept entries now get a trimmed or cleared URL, a cleared target and a top-level place in their own menu, each with a report line.
211 lines
14 KiB
Markdown
211 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.
|
|
- 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.
|