docs(32): pattern map
This commit is contained in:
540
.planning/phases/32-seo-foundation/32-PATTERNS.md
Normal file
540
.planning/phases/32-seo-foundation/32-PATTERNS.md
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user