Files
sm-seo-plugin/README.md
2026-10-27 11:04:42 +01:00

149 lines
9.9 KiB
Markdown

# golem15.seo
The SEO plugin for SummerCMS applications: an SEO tab on every content type, per-URL rules, global and per-type SEO settings, change history, and the effective SEO of any URL for the frontend.
```go
import _ "git.golem15.com/golem15/sm-seo-plugin"
```
Module `git.golem15.com/golem15/sm-seo-plugin`, package `seo`, plugin ID `golem15.seo`.
## Overview
The plugin owns all SEO data of a site; the framework only carries the generic seams it builds on: form extensions (`pact.AdminFormExtension`), record events (`pact.RecordSaved`, `pact.RecordDeleted`) and content descriptors (`pact.ContentDescriptor` in signpost). Content plugins never import it. A content type takes part by registering a content descriptor on its menu item type; the plugin then adds an **SEO** tab to that type's admin form and stores the values in its own table, keyed by the record's morph type, its id and the locale.
The frontend (a Nuxt layer or app) asks `GET /_seo/api/v1/resolve?url=...` for the effective SEO of the page it renders. Every value is resolved with one precedence: **URL rule > record > per-type template or global settings > values derived from the content**, and the resolver records the source of each value.
`Requires()` is empty: content types are found through signpost, never by plugin ID.
## Features
- **SEO tab** on every content type whose descriptor names an admin controller (`Content.Controller`), placed after the owner's last tab: result preview with duplicate and keyphrase checks, SEO title and meta description with pixel counters, focus keyphrase, canonical URL, robots directives (index/follow plus the advanced directives), social title, description and image, and the effective values with their sources. Values live in `golem15_seo_entries`, one row per record and locale; the owner's tables are untouched.
- **URL rules** (`golem15_seo_rules`): SEO for an exact address or a pattern (`/blog?tag=*`), with or without a record behind it. A rule wins over the record's tab.
- **Settings** (`golem15_seo_settings`, one row): site name, title and home templates with `%title%`, `%description%` and `%site%`, default description and social image, X account, the query-parameter list (content, filter and tracking parameters, optionally per path), never-indexed and search paths, address options and verification codes and files. One tab per content type (`golem15_seo_type_settings`) holds the inject switch, the title and description templates and the default robots (tag types default to `noindex,follow`).
- **Resolver.** Canonical URLs are self-referencing and absolute from the configured site URL, keep content parameters and drop filter and tracking parameters; overrides may be relative or absolute. Robots resolve per directive and are also answered as the `X-Robots-Tag` value. Open Graph and Twitter values inherit from the effective title, description and image. Verification meta tags come with the home page.
- **Non-production lock.** Outside the `production` environment every answer is `noindex, nofollow` with `staging: true`. It depends on the environment only; no setting turns it off.
- **History** (`golem15_seo_revisions`): every SEO write from the admin (the SEO tab, rules, settings) is recorded with author, time, via (admin or MCP) and the changed fields before and after. Deleted records keep their values in history.
- **Record events.** A deleted record loses its entries after commit; a created record gets its address snapshot.
- **Entry generation.** The `golem15.seo.generate_entries` job (conga) creates missing entries with the current address of every published record and refreshes the effective-value cache; editor values are never changed.
- **Field types** `seotext`, `mlseotext`, `seorobots`, `mlseorobots` and `seoimage`, with the custom elements `golem15-seo-text`, `golem15-seo-robots`, `golem15-seo-serp` (the result preview widget) and `golem15-seo-image`.
- **One permission**, `golem15.seo.manage`, for the SEO tab, rules, settings and history.
## Usage
Mount the plugin in an application as a git submodule at `plugins/golem15/seo`, add it to the application's `go.work` and `go.mod`, and list it in `summer.yaml`:
```yaml
plugins:
- id: golem15.seo
module: git.golem15.com/golem15/sm-seo-plugin
```
The plugin's own `go.mod` and `go.work` replace the framework with `../summercms.go` for a sibling checkout.
### Making a content type take part
A content plugin registers a content descriptor on its menu item type. `Controller` names the admin controller whose form gets the SEO tab and `Model` the records' morph type; `Describe` answers each record's URL, title, description and image per locale, and `Match` maps a public path to a record:
```go
func (p *Plugin) MenuItemTypes() []pact.MenuItemType {
return []pact.MenuItemType{{
Key: "acme-page",
Label: "acme.pages::lang.page",
DefaultPattern: "/pages/:slug",
Resolve: p.resolvePage,
Content: &pact.ContentDescriptor{
Controller: "acme.pages.pages",
Model: "acme_pages_pages",
Describe: p.describePage,
Records: p.pageRecords,
Match: p.matchPage,
},
}}
}
```
The type then gets the SEO tab, a tab in the SEO settings and a place in the resolver. A descriptor without `Controller` takes part in the settings and the resolver only.
### The resolve endpoint
`GET /_seo/api/v1/resolve?url=<path or absolute URL>` is public (no backend middleware) and throttled per client IP (bucket `seo-resolve`, 600 requests a minute). It answers `200` with:
```json
{
"action": "ok",
"status": 200,
"url": "https://example.com/pages/about",
"locale": "en",
"staging": false,
"seo": {
"title": "About | Acme",
"description": "Who we are",
"canonical": "https://example.com/pages/about",
"robots": "index, follow",
"x_robots_tag": "index, follow",
"og": {"title": "About | Acme", "description": "Who we are", "image": "https://example.com/storage/media/seo/abc-about.png", "url": "https://example.com/pages/about"},
"twitter": {"card": "summary_large_image", "site": "@acme"},
"verification": []
}
}
```
A path that no published record owns and no URL rule covers answers `"action": "not_found"`, `"status": 404` and `"seo": null` (the home page `/` is always known). A `url` that is neither a path nor an http(s) URL is a `422`. Absolute URLs are built from `golem15.seo.site_url`, never from the request's Host header.
### Admin
- Navigation item **SEO** (icon `search`) with the side menu **URL rules** and **History**.
- Settings tile **SEO settings** (`seo_settings`), a link to the settings singleton controller.
- The SEO tab, URL rules, settings and history require `golem15.seo.manage`. Without it an administrator sees no SEO tab, and a posted SEO value changes nothing.
The element bundle (`assets/dist/seo.js` and `seo.css`) is built from `assets/src/elements` and committed; the SEO field types load it on every admin page. After changing `assets/src`, run `npm ci`, `npm run build` and commit `assets/dist`; `scripts/check-dist.sh` fails when the committed bundle is stale.
## API reference
| Identifier | Description |
|------------|-------------|
| `Plugin` | The `golem15.seo` plugin; `Plugin.Service` returns the booted `classes.Service`. |
| `TabSEO`, `TabGeneral`, `BucketResolve` | The SEO tab key, the settings' General tab key and the resolve throttle bucket. |
| `classes.Service` | The single entry point for SEO reads and writes: `SaveRecord`, `Record` (history), `GenerateEntries`, `EnqueueGenerate`, `RecordDeleted`, `RecordCreated`, `Resolver`, `SiteURL`, `Staging`. |
| `classes.Resolver` | `Resolve(ctx, url)` answers a `classes.Result` with a source per value in `Result.Sources`; `ResolveRecord` resolves a record at its own address. |
| `classes.ParseRobots`, `classes.Robots` | The robots value grammar: parse, `String` (stored form), `Header` (meta and header form), `Inherit` (per-directive precedence). |
| `classes.RenderTemplate` | Fills a title or description template. |
| `classes.GenerateEntriesArgs` | The arguments of the generation job (`Type` limits it to one content type). |
| `classes.FieldTypes` | The SEO admin field types. |
| `classes.WithVia`, `classes.ViaOf` | The writer history records (`admin`, `mcp`, `import`, `auto`, `undo`). |
| `controllers.PermissionManage` | `golem15.seo.manage`. |
| `models.Entry`, `models.Rule`, `models.Settings`, `models.TypeSetting`, `models.Revision` | The plugin's models. |
## Configuration
| Key | Default | Meaning |
|-----|---------|---------|
| `golem15.seo.site_url` | `""` | The public origin of the site (`https://example.com`); absolute URLs start with it. Empty uses `app.url`. |
| `golem15.seo.unprefixed_locale` | `""` | The locale whose public URLs carry no locale prefix; empty leaves the choice to each content type. |
The non-production lock reads the application environment (`SUMMER_ENV`); it has no key of its own.
## CLI commands
| Command | Description |
|---------|-------------|
| `seo:generate-entries [--type <key>]` | Queues the entry generation job for every content type, or for one. A worker runs it. |
## Dependencies
- Framework modules: `pact`, `party`, `backpack`, `cabana`, `signpost`, `lagoon`, `conga`, `surf`, `wire`, `bouncer`, `phrasebook`, `bonfire`, `compass`, `diary`.
- `gorm.io/gorm`, `github.com/go-gormigrate/gormigrate/v2`, `gocloud.dev/blob` (the media bucket for social images), `github.com/goccy/go-yaml` (the per-type settings tabs document).
- Element toolchain (development only, exact pins): `typescript`, `vite`, `vitest`, `happy-dom`. The bundle has no runtime dependencies.
## Testing
```sh
go vet ./...
go test -short ./... # without Docker
go test ./... # with Docker: the Postgres smoke tests
npm ci && npm run typecheck && bash scripts/check-dist.sh
```
The smoke tests boot the plugin with a fake content plugin (`acme.pages`) and exercise the SEO tab, the settings tabs, the resolver precedence, history, generation and the URL rule form through the admin API.