Jakub Zych ec74062624 feat: verification files API
- GET /_seo/api/v1/verification/{name} serves a verification file stored
  in the SEO settings with its content type, nosniff and a sandboxing CSP
  (D-05, D-103); the name pattern is enforced in the route and the
  lookup, and no route is registered at the site root
2026-10-27 11:52:55 +01:00
2026-10-27 11:52:55 +01:00
2026-10-27 11:52:55 +01:00
2026-10-27 11:04:42 +01:00
2026-10-27 11:52:55 +01:00
2026-10-27 11:52:55 +01:00

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 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:

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.

Description
Golem15.SEO: per-record and global SEO, redirects, robots.txt and MCP tools for SummerCMS
Readme 1.3 MiB
Languages
Go 85.7%
TypeScript 12.2%
CSS 1.5%
HTML 0.5%