- golem15.seo.redirects list and form (UI-SPEC C8): chain rows marked, status, match, origin and chain filters, the navigation item lands here - a field-less extension on the controller refuses a loop with redirects.loop on target, answers the chain notice and records history - the status dropdown posts text, read through RedirectStatus
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.
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 ingolem15_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 tonoindex,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-Tagvalue. 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
productionenvironment every answer isnoindex, nofollowwithstaging: 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_entriesjob (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,mlseorobotsandseoimage, with the custom elementsgolem15-seo-text,golem15-seo-robots,golem15-seo-serp(the result preview widget) andgolem15-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:
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:
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:
{
"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
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.