diff --git a/.planning/phases/32-seo-foundation/32-PATTERNS.md b/.planning/phases/32-seo-foundation/32-PATTERNS.md new file mode 100644 index 00000000..e408ea57 --- /dev/null +++ b/.planning/phases/32-seo-foundation/32-PATTERNS.md @@ -0,0 +1,540 @@ +# Phase 32: SEO foundation - Pattern Map + +**Mapped:** 2026-10-26 +**Files analyzed:** 24 (framework seams, new `sm-seo-plugin`, `sm-journal-plugin` changes, `sm-demo-app` wiring) +**Analogs found:** 21 / 24 + +Paths are relative to the meta repo root `/media/nvme/dev/golem15/summercms.io/summercms/`. Every analog below is git-tracked in its own repo (checked with `git ls-files`; sibling repos need `git -c safe.directory='*'`). + +## File Classification + +| New/Modified File | Role | Data Flow | Closest Analog | Match Quality | +|---|---|---|---|---| +| `summercms.go/modules/pact/capabilities.go` (AdminActionExtension + Form, RecordSaved/RecordDeleted, MenuItemType.Content) | model (API types) | n/a | same file: `AdminActionExtension` (~429-439), `MCPSubjectChanged`, `MenuItemType` (~798-808) | exact | +| `summercms.go/modules/cabana/form_extensions.go` (new: placement compile pass) | service (boot compiler) | transform | `summercms.go/modules/cabana/action_extensions.go` | exact | +| `summercms.go/modules/cabana/http.go` (Activate wiring) | config | boot | same file lines 135-143 | exact | +| `summercms.go/modules/cabana/crud.go`, `extension.go`, `field_file.go`, `field_ml.go`, `form_schema.go` (extension-owned branch, lift values, Load/Save in tx) | service | CRUD | same files (owner-only path today) | exact (in-place) | +| `summercms.go/modules/cabana/record_events.go` (new: emit + EmitRecordSaved/Deleted helpers) | service | event-driven | `summercms.go/modules/cabana/subject_events.go` | exact | +| `summercms.go/modules/signpost/signpost.go` (+ ContentTypes/TypeForModel/MatchPath) | service (registry) | request-response | same file `Activate`/`add` (40-89) | exact | +| framework READMEs + docs (`modules/{pact,cabana,signpost,party}/README.md`, `docs/backend/admin-controllers.md`, `docs/plugins/*.md`) | docs | n/a | existing sections | exact | +| framework tests (`modules/cabana/form_extensions_test.go`, `record_events_test.go`, `modules/signpost/*_test.go`) | test | n/a | existing `action_extensions` tests in cabana | role-match | +| `sm-seo-plugin/plugin.go` | provider (plugin entry) | boot | `sm-sitemap-plugin/plugin.go` | exact | +| `sm-seo-plugin/admin.go` | config (admin registration) | n/a | `sm-sitemap-plugin/admin.go` | exact | +| `sm-seo-plugin/admin_permissions.go` | config | n/a | `sm-sitemap-plugin/admin_permissions.go` | exact | +| `sm-seo-plugin/extensions.go` (AdminActionExtensions with Form) | provider | boot | `sm-paymentgateway-plugin/controllers/orders.go` 106-155 (record action shape) + cabana `action_extensions.go` (consumer) | partial (no plugin implements extensions yet) | +| `sm-seo-plugin/field_types.go` (+ `classes/field_types.go`) | component (field types) | transform | `sm-journal-plugin/field_types.go` + `classes/blog_editor.go` 80-109 | exact | +| `sm-seo-plugin/listeners.go` | service | event-driven | `subject_events.go` (emitter) + `modules/festival/bus.go` Listen | role-match | +| `sm-seo-plugin/routes.go` (`/robots.txt`, `/_seo/api/v1/*`) | route | request-response | `sm-journal-plugin/routes.go` + `sm-sitemap-plugin/routes.go` | exact | +| `sm-seo-plugin/controllers/api/*.go` (resolve, management handlers) | controller | request-response | `sm-journal-plugin/controllers/api/posts.go` + `auth.go` | exact | +| `sm-seo-plugin/mcp.go` | config (MCP tools) | request-response | `sm-journal-plugin/mcp.go` | exact | +| `sm-seo-plugin/jobs.go` (GenerateEntries) | service (job) | batch | `sm-chat-plugin/plugin.go` 85-100 | exact | +| `sm-seo-plugin/controllers/{redirects,rules,notfound,history}.go` | controller | CRUD | `sm-paymentgateway-plugin/controllers/orders.go` | exact | +| `sm-seo-plugin/controllers/{settings,robots,transfer}.go` (AdminSingleton) | controller | CRUD | `sm-sitemap-plugin/controllers/definitions.go` | exact | +| `sm-seo-plugin/models/*.go` + `models/*/fields.yaml`, `columns.yaml` | model | CRUD | `sm-sitemap-plugin/models/settings.go` + `models/settings/fields.yaml` | exact | +| `sm-seo-plugin/updates/*.go` | migration | n/a | `sm-sitemap-plugin/updates/registry.go` + `202610110002_create_golem15_sitemap_settings.go` | exact | +| `sm-seo-plugin/classes/*` (resolver, redirect graph, normaliser, robots, history, batch, csv) | service | transform | none (pure functions) | no analog | +| `sm-seo-plugin/assets/src/*` (E1-E6 elements, Vite) | component | client | `sm-journal-plugin/assets/src/blogeditor/*`, `vite.config.ts`, `package.json` | exact | +| `sm-seo-plugin/testmain_test.go` | test | n/a | `sm-sitemap-plugin/testmain_test.go` | exact | +| `sm-journal-plugin/classes/menu_item_types.go` (+ Content descriptors, `journal-tag` type) | service | request-response | same file 98-140 | exact | +| `sm-journal-plugin/controllers/api/posts.go` (Store/Update/Destroy emit record events) | controller | CRUD | same file 240-320 (existing `purgeSiteCache` after-commit call) | exact | +| `sm-demo-app/summer.yaml`, `.gitmodules` (mount `plugins/golem15/seo`) | config | n/a | sitemap entries in same files | exact | + +## Pattern Assignments + +### `summercms.go/modules/pact/capabilities.go` (API types) + +**Analog:** same file. Extend with keyed-field additions only, so existing keyed literals keep compiling. + +Current struct (429-439): +```go +type AdminActionExtension struct { + Controller string + RecordActions []AdminRecordAction +} + +// HasAdminActionExtensions is implemented by plugins (not admin controllers) +// that add actions to admin controllers by controller ID. +type HasAdminActionExtensions interface { + AdminActionExtensions() []AdminActionExtension +} +``` +The doc comment above it (418-428) lists every boot error in prose; the new `Form` field's comment must list the D-91 boot errors the same way (unknown anchor, duplicate name, cycle, anchor outside `Requires()`). + +`MenuItemType` (798-808) uses `json:"-"` on func fields; new `Content *ContentDescriptor` follows that: +```go + References func(ctx context.Context) ([]MenuItemReference, error) `json:"-"` + Resolve func(ctx context.Context, in MenuItemResolve) (*ResolvedMenuItem, error) `json:"-"` +``` +Event types sit next to `pact.MCPSubjectChanged` (fired as a pointer, `&pact.MCPSubjectChanged{...}`), so `RecordSaved`/`RecordDeleted` are fired and listened to as pointers too. + +--- + +### `summercms.go/modules/cabana/form_extensions.go` (boot compiler, transform) + +**Analog:** `summercms.go/modules/cabana/action_extensions.go` 25-65 + +```go +func compileActionExtensions(reg *Registry, plugins []party.Plugin) error { + if reg == nil { + return fmt.Errorf("cabana: registry is nil") + } + for _, plugin := range plugins { + if plugin == nil { + continue + } + src, ok := plugin.(pact.HasAdminActionExtensions) + if !ok || src == nil { + continue + } + pluginID := plugin.ID() + for _, ext := range src.AdminActionExtensions() { + if len(ext.RecordActions) == 0 { + continue + } + cc, ok := reg.byID[ext.Controller] + if !ok || cc == nil { + return fmt.Errorf("cabana: plugin %s adds record actions to unknown admin controller %q", pluginID, ext.Controller) + } + ... + // A new slice and map: the compiled backing array may be + // shared with another schema value. + names := make([]string, 0, len(cc.Form.recordActions)+1) +``` +Copy: iterate `plugins` in party order (already topo-sorted), type-assert the capability, error messages of form `cabana: plugin %s ... controller %q`, and copy-on-write of compiled slices/maps. Skip the extension when `ext.Form == nil` (mirrors `len(ext.RecordActions) == 0`). + +**Wiring** (`modules/cabana/http.go` 135-143): call `compileFormExtensions(reg, plugins)` before `compileActionExtensions`: +```go + // Contributed record actions are merged before compileContributions, + // which validates every record action's permissions. + if err := compileActionExtensions(reg, plugins); err != nil { + return nil, err + } +``` +Owner-only checks that need an "extension-owned" branch (RESEARCH Pitfalls 3-7): `extension.go:132,145,150` (widget prefix, fill, AdminClientAssets), `extension.go:231-233` (partials from owner FS), `field_file.go:309-326` (fileupload), `crud.go:256` (`BindWritableFields` "is not a model column"), `http.go:857-866` (`hiddenSchemaFields`, schema-only — apply to save too). + +--- + +### `summercms.go/modules/cabana/record_events.go` (event-driven) + +**Analog:** `summercms.go/modules/cabana/subject_events.go` 17-44 + +```go +func fireSubjectChanged(ctx context.Context, app *backpack.App, kind string, adminIDs ...uint) { + if app == nil || app.Events == nil { + return + } + if ctx == nil { + ctx = context.Background() + } + tx, _ := TxFromContext(ctx) + lagoon.AfterCommit(ctx, tx, func(ctx context.Context, _ *gorm.DB) { + for _, id := range adminIDs { + ... + event := &pact.MCPSubjectChanged{Guard: backendSubjectGuard, UserID: id, Kind: kind} + if err := app.Events.Fire(ctx, event); err != nil { + moduleLog.WarnContext(ctx, "cabana: subject changed listener failed", "kind", kind, "admin_id", id, "error", err) + } + } + }) +} +``` +Copy exactly: nil app/bus is a no-op, fire after commit, listener error only logged (never fails the save). Exported `EmitRecordSaved/EmitRecordDeleted(ctx, app, tx, ev)` take an explicit `tx` (journal handlers have no cabana tx in ctx). Doc comment style is the 8-line paragraph above `fireSubjectChanged`. + +--- + +### `summercms.go/modules/signpost/signpost.go` (registry) + +**Analog:** same file, `Activate` (40-66) and `add` (68-89). Validation of the new `Content` descriptor goes into `add` with the same message shape: +```go + if t.Resolve == nil { + return fmt.Errorf("signpost: menu item type %q of plugin %s has no Resolve", t.Key, owner) + } +``` +New read helpers follow `Types()` (132) / `Type(key)` (140) style; `MatchPath` compiles patterns via the existing `Pattern(key, override)` (161). + +--- + +### `sm-seo-plugin/plugin.go` (plugin entry) + +**Analog:** `sm-sitemap-plugin/plugin.go` 1-80 + +```go +import ( + "git.golem15.com/golem15/sm-sitemap-plugin/classes" + "git.golem15.com/golem15/sm-sitemap-plugin/models" + "git.golem15.com/golem15/sm-sitemap-plugin/updates" + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/pact" + "git.golem15.com/golem15/summercms/modules/party" + "git.golem15.com/golem15/summercms/modules/surf" + "github.com/go-gormigrate/gormigrate/v2" +) + +var ( + _ party.Plugin = (*Plugin)(nil) + _ pact.HasConfig = (*Plugin)(nil) + _ pact.HasMigrations = (*Plugin)(nil) + _ pact.HasModels = (*Plugin)(nil) + _ pact.HasRoutes = (*Plugin)(nil) + ... +) + +type Plugin struct { + app *backpack.App + ... +} + +func (p *Plugin) ID() string { return "golem15.sitemap" } +func (p *Plugin) Requires() []string { return nil } +func (p *Plugin) Register(app *backpack.App) error { p.app = app; return nil } +// Boot ... a bad config fails the boot with "golem15.sitemap: %w" +``` +Copy: package doc comment, compile-time interface assertions block, `p.app` stored in Register/Boot, boot errors prefixed `golem15.seo:`. `Requires()` lists only what the seams need (not journal). Add `pact.HasJobs`, `pact.HasMCP`, `pact.HasFormFieldTypes`, `pact.HasAdminActionExtensions` assertions. + +--- + +### `sm-seo-plugin/admin.go` + `admin_permissions.go` + +**Analog:** `sm-sitemap-plugin/admin.go` (whole file) and `admin_permissions.go` + +```go +//go:embed controllers/definitions/config_form.yaml models/definition/fields.yaml models/settings/fields.yaml +var adminFS embed.FS + +//go:embed lang +var langFS embed.FS + +func (p *Plugin) AdminControllers() []pact.AdminController { + return controllers.AdminControllers(func() *backpack.App { + if p == nil { + return nil + } + return p.app + }) +} + +func (p *Plugin) Settings() []pact.SettingsItem { + return []pact.SettingsItem{ + { + Code: "sitemap", + Label: "golem15.sitemap::lang.plugin.name", + ... + Permissions: []string{controllers.PermissionAccessDefinitions}, + Controller: controllers.DefinitionsControllerID, + }, +``` +Use the **link-entry** form (`Controller:`), never `Form:`: the SEO settings need widgets/partials/plugin field types, which `cabana/settings.go:96-104` refuses (RESEARCH Pattern 4). Embed YAML file by file (controllers dir also holds Go). Permissions: +```go +func (*Plugin) Permissions() []pact.Permission { + return []pact.Permission{{ + Code: controllers.PermissionAccessDefinitions, + Tab: "golem15.sitemap::lang.plugin.name", + Label: "golem15.sitemap::lang.plugin.permissions.access_definitions", + Roles: []string{"developer"}, + }} +} +``` +One permission `golem15.seo.manage` (D-15). + +--- + +### `sm-seo-plugin/controllers/{settings,robots,transfer}.go` (AdminSingleton) + +**Analog:** `sm-sitemap-plugin/controllers/definitions.go` 19-80, 155-157 + +```go +const ( + DefinitionsControllerID = "golem15.sitemap.definitions" + PermissionAccessDefinitions = "golem15.sitemap.access_definitions" +) + +type definitionsAdminController struct { + app func() *backpack.App +} + +func (definitionsAdminController) ID() string { return DefinitionsControllerID } +func (definitionsAdminController) ModelName() string { return `Golem15\Sitemap\Models\Definition` } +func (definitionsAdminController) ConfigDir() string { return "controllers/definitions" } +func (definitionsAdminController) RequiredPermissions() []string { return []string{PermissionAccessDefinitions} } +func (definitionsAdminController) NewRecord() any { return &models.Definition{} } + +func (definitionsAdminController) SingletonRecord(_ context.Context, db *gorm.DB) (any, error) { + if db == nil { + return nil, errors.New("golem15.sitemap: database is not configured") + } + ... find first, else create ... +} + +var ( + _ pact.AdminPermissioned = definitionsAdminController{} + _ pact.AdminSingleton = definitionsAdminController{} +) +``` +`controllers/admin_registry.go` `AdminControllers(app func() *backpack.App) []pact.AdminController` is the list constructor to copy. Per-type tabs on the settings form come from the plugin's own D-91 extension on this controller. + +--- + +### `sm-seo-plugin/controllers/{redirects,rules,notfound,history}.go` (list/form/preview + record actions) + +**Analog:** `sm-paymentgateway-plugin/controllers/orders.go` 24-50, 106-155, 299-312 + +```go +func (c ordersAdminController) AdminRecordActions() []pact.AdminRecordAction { + ... + out = append(out, pact.AdminRecordAction{ + Name: a.Name, + Label: lang + a.Label, + Confirm: lang + a.Confirm, + Permissions: []string{PermissionManageOrders}, + Danger: a.Danger, + Applies: func(ctx context.Context, record any) (bool, error) { ... }, + Run: func(ctx context.Context, in pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) { + return c.runTransition(ctx, in, a) + }, + }) +``` +Plus `FilterOptions(scope)`, `ListExtendQuery`, `FormBeforeUpdate/FormBeforeDelete`, `FormRules`. 404 log "Create redirect…" adds `Prompt` (see `pact/capabilities.go:418-422`). Every hook delegates to the one service package in `classes/` (history, loop check, purge). + +--- + +### `sm-seo-plugin/models/*` and `updates/*` + +**Analog:** `sm-sitemap-plugin/models/settings.go` 35-70; `updates/registry.go` 1-17; `updates/202610110002_create_golem15_sitemap_settings.go` + +```go +type Settings struct { + ID uint `gorm:"column:id;primaryKey"` + FrontendURL *string `gorm:"column:frontend_url"` + ... +} +func (Settings) TableName() string { return "golem15_sitemap_settings" } +func (Settings) Fillable() []string { return []string{"frontend_url", ...} } +``` +```go +var createSettings = []*gormigrate.Migration{ + { + ID: "202610110002_create_golem15_sitemap_settings", + Migrate: func(tx *gorm.DB) error { + for _, stmt := range []string{ `CREATE TABLE ...`, `INSERT INTO ... (id) VALUES (1)` } { + if err := tx.Exec(stmt).Error; err != nil { return err } + } + return nil + }, + Rollback: func(tx *gorm.DB) error { return tx.Exec(`DROP TABLE IF EXISTS ...`).Error }, + }, +} +func init() { Register(createSettings...) } +``` +Raw SQL DDL, timestamped IDs, one file per table, table prefix `golem15_seo_`. Nullable columns as pointers. Validation in `Rules()`/`BeforeValidate` with phrase-key errors (`errFrontendURL` const pattern, settings.go:13-14). fields.yaml header comment block as in `models/settings/fields.yaml` 1-9. + +--- + +### `sm-seo-plugin/routes.go` + `controllers/api/*.go` + +**Analog:** `sm-journal-plugin/routes.go` 9-36 (groups) and `sm-sitemap-plugin/routes.go` 43-60 (root route + bucket) + +```go +func (p *Plugin) Routes(r pact.Router) error { + r.Group("/_journal/api/v1", surf.Use("throttle:journal-public-api"), func(g pact.Router) { + g.Get("/posts", apicontrollers.Index(p.app)) + ... + }) + r.Group("/_journal/api/v1", surf.Use("throttle:journal-api"), func(g pact.Router) { + g.Put("/posts/{id}", apicontrollers.Update(p.app)) + g.Where("id", `[0-9]+`) + }) + return nil +} +``` +```go + r.Get("/sitemap.xml", p.sitemapXML, "throttle:"+BucketSitemap) +... +func (p *Plugin) Buckets() map[string]surf.Bucket { + trusted := surf.TrustedProxies(nil) + if p.app != nil { trusted = surf.TrustedProxies(p.app.Config) } +``` +`GET /robots.txt` copies the sitemap root-route shape (no backend middleware, throttled, never 5xx). Do not register `GET /{name}` (verification files; RESEARCH anti-pattern). + +Handler shape: `sm-journal-plugin/controllers/api/posts.go` 240-291 and `auth.go` 83-115: +```go +func Update(app *backpack.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + r, pr, ok := requireBackendPrincipal(app, w, r) + if !ok { return } + if !requireAccessPosts(w, pr) { return } + db, err := gdb(app) + ... + wire.WriteJSON(w, http.StatusOK, map[string]any{"data": ...}) + } +} +func requireBackendPrincipal(app *backpack.App, w http.ResponseWriter, r *http.Request) (*http.Request, *bouncer.Principal, bool) { + out, pr, err := cabana.ResolveBackend(app, r, journalCover) + ... +} +func gdb(app *backpack.App) (*gorm.DB, error) { + db, ok := app.Lookup[*gorm.DB]() +``` +Permission check via `cabana.Allows(pr, []string{"golem15.seo.manage"})`. MCP detection for history `via`: `bouncer.Dispatched(r.Context(), "backend")`. + +--- + +### `sm-seo-plugin/mcp.go` + +**Analog:** `sm-journal-plugin/mcp.go` 12-110 + +```go +var _ pact.HasMCP = (*Plugin)(nil) +const mcpGuard = "backend" + +type mcpUpdatePostInput struct { + ID uint `json:"id" path:"id" jsonschema:"the post id"` + Title *string `json:"title,omitempty" jsonschema:"the post title"` +} + +func (p *Plugin) MCP() pact.MCP { + destructive := true + return pact.MCP{ + Tools: []pact.MCPTool{ + { + Name: "list_posts", + Title: "List posts", + Description: "...", + Guard: mcpGuard, + Method: http.MethodGet, + Path: "/_journal/api/v1/posts", + Input: mcpListPostsInput{}, + Permissions: []string{mcpAccessPosts}, + }, +``` +Tools bind only to `/_seo/api/v1/*` routes (pier requires exact method+path; `modules/pier/catalog.go:178-182`). Batch tools carry `dry_run` in the input struct. + +--- + +### `sm-seo-plugin/jobs.go` + +**Analog:** `sm-chat-plugin/plugin.go` 85-100 +```go +func (p *Plugin) Jobs() []pact.Job { + return []pact.Job{ + conga.Job(p.maintainThreadSummary, conga.OnQueue(jobs.Queue), conga.MaxAttempts(1)), + } +} +func (p *Plugin) maintainThreadSummary(ctx context.Context, args jobs.MaintainThreadSummaryArgs) error { + if p.service == nil { return errServiceMissing } + return jobs.Work(ctx, p.service.DB(), args) +} +``` + +--- + +### `sm-seo-plugin/field_types.go` + `classes/field_types.go` (E1, E2, E4 types) + +**Analog:** `sm-journal-plugin/field_types.go` 1-21 and `classes/blog_editor.go` 80-109 +```go +func (p *Plugin) FormFieldTypes() []pact.FormFieldType { + return classes.BlogEditorTypes(func() *backpack.App { if p == nil { return nil }; return p.app }) +} + + { + Name: "mlblogeditor", + Element: BlogEditorElement, + Translatable: true, + Scripts: []string{"assets/dist/blogeditor.js"}, + Styles: []string{"assets/dist/blogeditor.css"}, + Options: []string{"htmlColumn", "uploadFolder"}, + CheckOptions: checkBlogOptions, + Decode: decodeBlogHTML, + Load: loadBlogHTML, + ListValue: blogListValue, + }, +``` +Pairs `seotext`/`mlseotext`, `seorobots`/`mlseorobots`, plus `robotstxt`. Bundle listed in `mlseotext.Scripts` so it loads on other plugins' forms. Note Pitfall 6: as extension fields, storage diverts to the extension's Save, not the host column. + +--- + +### `sm-seo-plugin/listeners.go` + +**Analog:** emitter `cabana/subject_events.go` (above); bus signature `modules/festival/bus.go:43-46`: +```go +app.Events.Listen("golem15.seo", func(ctx context.Context, ev *pact.RecordSaved) error { + return p.service.OnRecordSaved(ctx, ev) +}) +``` +Register in `Boot`. + +--- + +### `sm-seo-plugin/testmain_test.go` + +**Analog:** `sm-sitemap-plugin/testmain_test.go` 19-70 (copy verbatim, rename DB/user to `golem15_seo`): `flag.Parse()` then `testing.Short()`, one `postgres:16-alpine` container, `pgx` stdlib driver. + +--- + +### `sm-journal-plugin/classes/menu_item_types.go` (D-93 descriptors, `journal-tag`) + +**Analog:** same file 19-140. Add `MenuTypeTag = "journal-tag"` to the key const block (21-27), a label const, a `DefaultTagPattern`, and `Content: &pact.ContentDescriptor{...}` on post/category/tag entries using `m := menuTypes{app: app}` methods, keyed literals as at 101-127. + +### `sm-journal-plugin/controllers/api/posts.go` (record events) + +**Analog:** same file. Store/Update/Destroy already call `purgeSiteCache(r, app, post)` → `cooler.PurgeSiteCacheAfterCommit(r.Context(), nil, ...)` (line 400). Add `cabana.EmitRecordSaved/EmitRecordDeleted` next to that call (after `Save`/`Delete`, same nil-tx semantics). Capture old slug/URL before `applyPostUpdate` (line 268) for the `Changes` list. + +### `sm-demo-app/summer.yaml` + `.gitmodules` + +**Analog:** sitemap entries: +```yaml + - id: golem15.sitemap + module: git.golem15.com/golem15/sm-sitemap-plugin +``` +``` +[submodule "plugins/golem15/sitemap"] + path = plugins/golem15/sitemap + url = git@git.golem15.com:golem15/sm-sitemap-plugin.git + branch = main +``` +Add `golem15.seo` after sitemap and journal (it requires sitemap per D-95 in Phase 33). + +### `sm-seo-plugin/assets/src/*` + +**Analog:** `sm-journal-plugin/assets/src/blogeditor/` (incl. `labels.ts` locale dictionary), `sm-journal-plugin/vite.config.ts`, `package.json` (pins typescript 5.9.3, vite 7.3.5, vitest 3.2.7, happy-dom 20.11.6), `scripts/` dist check. Light DOM, CSS scoped under the element tag, `--c-*` variables only. + +## Shared Patterns + +### Error messages and boot failures +**Source:** `cabana/action_extensions.go:44`, `signpost/signpost.go:68-82`, `sm-sitemap-plugin/plugin.go` Boot +**Apply to:** all seams and plugin Boot. Prefix with module/plugin ID (`cabana:`, `signpost:`, `golem15.seo:`), name the plugin, controller and offending key; `fmt.Errorf("...: %w", err)` wrapping. + +### After-commit side effects +**Source:** `cabana/subject_events.go:25-44`, `cooler.PurgeSiteCacheAfterCommit` (journal posts.go:400) +**Apply to:** record events, site-cache purge after any SEO write, history-dependent notifications. Listener errors logged, never fail the write. + +### Lazy app accessor +**Source:** `sm-sitemap-plugin/admin.go` AdminControllers, `sm-journal-plugin/field_types.go` +**Apply to:** controllers, field types, API handlers: pass `func() *backpack.App`, read DB via `app.Lookup[*gorm.DB]()` at request time. + +### Auth for plugin JSON API +**Source:** `sm-journal-plugin/controllers/api/auth.go:83-115` +**Apply to:** every `/_seo/api/v1` write/management handler; one permission `golem15.seo.manage`. + +### Phrase keys +**Source:** sitemap `golem15.sitemap::lang.*` constants +**Apply to:** all labels, comments and errors: `golem15.seo::lang.*`, `lang/{en,pl}/lang.yaml`, keys as listed in 32-UI-SPEC.md. + +### Tests +**Source:** `sm-sitemap-plugin/testmain_test.go`; framework `go test -short` loop +**Apply to:** last plan; pure-function tests (placement, redirect graph, normaliser, resolver precedence) need no DB. + +## No Analog Found + +| File | Role | Data Flow | Reason | +|---|---|---|---| +| `sm-seo-plugin/classes/{resolver,redirects,normalize}.go` | service | transform | New algorithms (precedence resolver, redirect graph flatten/loop simulation, URL normalisation); use RESEARCH Patterns 5, 6, Pitfalls 10, 11, 13 and the wildcard-regexp example | +| `sm-seo-plugin/classes/robots.go` | service | transform | No robots.txt code exists; grobotstxt pending decision note (RESEARCH Standard Stack) | +| `sm-seo-plugin/classes/{batch,history,csv}.go` | service | batch | Dry-run-by-rollback and HMAC apply token are new (RESEARCH Pattern 8); use `lagoon.Transaction` + `encoding/csv` | + +Also note: no plugin implements `HasAdminActionExtensions` today, so `sm-seo-plugin/extensions.go` is the first consumer; use the framework test fixtures in `modules/cabana` for shape. + +## Metadata + +**Analog search scope:** `summercms.go/modules/{pact,cabana,signpost,festival,boardwalk}`, `sm-sitemap-plugin`, `sm-journal-plugin`, `sm-paymentgateway-plugin`, `sm-chat-plugin`, `sm-demo-app` +**Files scanned:** ~30 +**Pattern extraction date:** 2026-10-26