docs(32-05): sm-demo-app mount patch for golem15.seo

- proven in a scratch worktree of sm-demo-app 8fcdbdd: TestBootAllPlugins,
  TestSEOTabMounted and TestSEOToolsCatalog pass
- summer.yaml, config/golem15.seo.yaml, the nginx /robots.txt location,
  DEPLOY.md, boot_test.go, plugins.gen.go and the two SEO tests; plan
  32-06 applies it after the plugin tag
This commit is contained in:
2026-10-27 12:48:31 +01:00
parent 9247761c94
commit 20d02d722a

View File

@@ -0,0 +1,324 @@
32-05 demo mount of golem15.seo (sm-seo-plugin), proven in a scratch worktree
of sm-demo-app at 8fcdbdd. Plan 32-06 applies it after the plugin and
framework tags, from the sm-demo-app root:
git apply .../32-05-demo-mount.patch
and then, in the same release commit or separate commits as DEPLOY.md
describes: add the plugin submodule at plugins/golem15/seo
(.gitmodules, branch main), add ./plugins/golem15/seo to go.work, require
the tagged git.golem15.com/golem15/sm-seo-plugin (and the tagged
sm-journal-plugin with the 32-02 descriptors) in go.mod, run summer build
(plugins.gen.go below is what it generates), and pass TestGoModRequiresTags.
The patch adds the plugin to summer.yaml after golem15.sitemap, the
plugin config, the nginx /robots.txt location, the DEPLOY.md section, the
plugin in TestBootAllPlugins, and the two tests TestSEOTabMounted and
TestSEOToolsCatalog (summercms_mcp), which skip with "NOT MOUNTED
golem15.seo" while summer.yaml does not list the plugin.
Scratch run (GOWORK with ../sm-journal-plugin and ../sm-seo-plugin):
--- PASS: TestBootAllPlugins
--- PASS: TestSEOTabMounted
--- PASS: TestSEOToolsCatalog
diff --git a/DEPLOY.md b/DEPLOY.md
index edc10eb..a964219 100644
--- a/DEPLOY.md
+++ b/DEPLOY.md
@@ -407,6 +407,26 @@ and add `http://127.0.0.1:3020/mcp` to the client. With the default `app.url` (`
the endpoint answers on the Go port, but the consent redirect goes to `/connect` on Go, which has no such
page.
+### SEO (golem15.seo)
+
+The demo mounts sm-seo-plugin: an SEO tab on Journal posts, categories and tags, URL rules, redirects,
+the 404 log, robots.txt with AI bot switches, history and the `seo_` MCP tools (the administrator needs
+`golem15.seo.manage`). `config/golem15.seo.yaml` sets `site_url` to `https://demo.summercms.io`.
+
+- **robots.txt moves to Go.** nginx sends `= /robots.txt` to the Go port (`deploy/nginx/demo.summercms.io.conf`);
+ the Nuxt robots module no longer answers it. Before the first release with the plugin, copy the
+ `Disallow` lines the Nuxt config writes today (`goPaths` in `vue-demo-app/nuxt.config.ts`: `/_user`,
+ `/_journal`, `/_menu`, `/_faq`, `/_paymentgateway`, `/_paymentgatewaysubscriptions`, `/_feedback`,
+ `/storage`, `/_presentations`, `/_quizzes`, `/_demo`, `/api/realtime/token`, `/mcp`,
+ `/.well-known/oauth-protected-resource`, `/.well-known/oauth-authorization-server`, `/oauth/mcp/`) into
+ **SEO › robots.txt** under `User-agent: *`, and add `/_seo`. The `Sitemap:` line is added by the plugin.
+ Outside production the plugin serves `Disallow: /` whatever is stored.
+- **404 log.** Set `SUMMER_GOLEM15__SEO__NOTFOUND_TOKEN` in `.env` on rome (a random value, never printed
+ or committed) and the same token in the Nuxt site's environment once the frontend layer reports 404s
+ (Phase 33); without it the report endpoint stays off.
+- **Verify** after the release: `curl -s https://demo.summercms.io/robots.txt` shows the copied lines and
+ the `Sitemap:` line; `curl -sI https://demo.summercms.io/robots.txt` answers 200 `text/plain`.
+
## Build
```sh
diff --git a/boot_test.go b/boot_test.go
index ca053ea..7eba15b 100644
--- a/boot_test.go
+++ b/boot_test.go
@@ -19,7 +19,7 @@ import (
// wantPlugins is every plugin of the demo, in summer.yaml order.
var wantPlugins = []string{
- "golem15.user", "golem15.translate", "golem15.journal", "golem15.sitemap", "golem15.menu",
+ "golem15.user", "golem15.translate", "golem15.journal", "golem15.sitemap", "golem15.seo", "golem15.menu",
"golem15.faq", "golem15.paymentgateway", "golem15.pgstripe", "golem15.paymentgatewaysubscriptions",
"golem15.pgstripesubscriptions", "golem15.presentations", "golem15.quizzes", "golem15.golem",
"golem15.feedback", "golem15.oauthserver", "golem15.sociallogin", "golem15.mfa", "golem15.demo",
@@ -59,6 +59,7 @@ func TestBootAllPlugins(t *testing.T) {
"/_user/api/v1", "/_journal/api/v1", "/_menu/api/v1", "/_faq/api/v1",
"/_paymentgatewaysubscriptions/api/v1", "/webhook/stripe/subscriptions", "/sitemap.xml", "/smbackend", "/_demo/api/v1", "/_demo/api/v1/presentations",
"/oauth/", "/_user/api/v1/oauth-complete", "/_user/api/v1/2fa/", "/_user/api/v1/devices",
+ "/_seo/api/v1", "/robots.txt",
} {
if !routed(rt.Routes(), prefix) {
t.Errorf("no route under %s", prefix)
diff --git a/config/golem15.seo.yaml b/config/golem15.seo.yaml
new file mode 100644
index 0000000..dbea376
--- /dev/null
+++ b/config/golem15.seo.yaml
@@ -0,0 +1,12 @@
+# golem15.seo (sm-seo-plugin): SEO of the Journal posts, categories and tags,
+# URL rules, redirects, the 404 log, robots.txt and the SEO MCP tools.
+#
+# site_url is the public origin the Nuxt site serves; canonical URLs,
+# Open Graph URLs and robots.txt Sitemap lines are built from it, never from
+# a request's Host. The 404 report token comes from the environment
+# (SUMMER_GOLEM15__SEO__NOTFOUND_TOKEN, .env on rome; never committed);
+# without it the report endpoint is off.
+site_url: https://demo.summercms.io
+notfound_token: ""
+robots_sitemaps:
+ - /sitemap.xml
diff --git a/deploy/nginx/demo.summercms.io.conf b/deploy/nginx/demo.summercms.io.conf
index 9b8aaa1..c179b49 100644
--- a/deploy/nginx/demo.summercms.io.conf
+++ b/deploy/nginx/demo.summercms.io.conf
@@ -299,6 +299,17 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
}
+ # robots.txt comes from golem15.seo: the rules and AI bot switches of
+ # SEO › robots.txt in production, the lock (Disallow: /) elsewhere.
+ location = /robots.txt {
+ proxy_pass http://127.0.0.1:8098;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+
# The sitemap plugin's document and its stylesheet.
location = /sitemap.xml {
proxy_pass http://127.0.0.1:8098;
diff --git a/plugins.gen.go b/plugins.gen.go
index b13599f..a4cd28f 100644
--- a/plugins.gen.go
+++ b/plugins.gen.go
@@ -17,6 +17,7 @@ import (
_ "git.golem15.com/golem15/sm-pgstripe-plugin/subscriptions"
_ "git.golem15.com/golem15/sm-presentations-plugin"
_ "git.golem15.com/golem15/sm-quizzes-plugin"
+ _ "git.golem15.com/golem15/sm-seo-plugin"
_ "git.golem15.com/golem15/sm-sitemap-plugin"
_ "git.golem15.com/golem15/sm-sociallogin-plugin"
_ "git.golem15.com/golem15/sm-translate-plugin"
@@ -29,6 +30,7 @@ var PluginIDs = []string{
"golem15.translate",
"golem15.journal",
"golem15.sitemap",
+ "golem15.seo",
"golem15.menu",
"golem15.faq",
"golem15.paymentgateway",
diff --git a/seo_mcp_test.go b/seo_mcp_test.go
new file mode 100644
index 0000000..5ac56cc
--- /dev/null
+++ b/seo_mcp_test.go
@@ -0,0 +1,54 @@
+//go:build summercms_mcp
+
+package main
+
+import (
+ "net/http"
+ "slices"
+ "testing"
+
+ "git.golem15.com/golem15/summercms/modules/pact"
+)
+
+// TestSEOToolsCatalog checks SC-4 through the demo's MCP endpoint: an
+// administrator with golem15.seo.manage, linked to the MCP user with the
+// backend scope, is listed seo_ plus every tool the registered SEO plugin
+// declares (pier binds each to its route at boot); a read grant lists the
+// GET tools only. The plugin is found through the activated plugins and
+// pact.HasMCP, so the demo imports no SEO package.
+func TestSEOToolsCatalog(t *testing.T) {
+ skipUnlessSEOMounted(t)
+ env := bootMCPDemo(t)
+ var tools []pact.MCPTool
+ for _, p := range env.demo.Plugins {
+ if p.ID() != seoPluginID {
+ continue
+ }
+ hm, ok := p.(pact.HasMCP)
+ if !ok {
+ t.Fatalf("%s declares no MCP tools", seoPluginID)
+ }
+ tools = hm.MCP().Tools
+ }
+ if len(tools) == 0 {
+ t.Fatalf("%s is mounted but activated no tools", seoPluginID)
+ }
+ editor := env.frontendUser(t, "seo")
+ env.link(t, editor.ID, env.editorAdmin(t, "seo", "golem15.seo.manage"))
+ client := env.mcpClient(t, "demo-seo-agent")
+
+ names := toolNames(t, env.connect(t, env.mint(t, editor.ID, client, "read", "write", "backend")))
+ for _, tool := range tools {
+ if !hasTool(names, "seo_"+tool.Name) {
+ t.Errorf("catalog %v lacks seo_%s", names, tool.Name)
+ }
+ }
+
+ reads := toolNames(t, env.connect(t, env.mint(t, editor.ID, client, "read", "backend")))
+ for _, tool := range tools {
+ listed := slices.Contains(reads, "seo_"+tool.Name)
+ if get := tool.Method == http.MethodGet; listed != get {
+ t.Errorf("read grant: seo_%s listed %v, want %v (method %s)", tool.Name, listed, get, tool.Method)
+ }
+ }
+}
diff --git a/seo_mount_test.go b/seo_mount_test.go
new file mode 100644
index 0000000..9c08cc0
--- /dev/null
+++ b/seo_mount_test.go
@@ -0,0 +1,109 @@
+package main
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "slices"
+ "strconv"
+ "testing"
+ "time"
+
+ "git.golem15.com/golem15/sm-demo-app/internal/demotest"
+ "git.golem15.com/golem15/summercms/modules/bouncer"
+ "git.golem15.com/golem15/summercms/modules/cabana"
+ "git.golem15.com/golem15/summercms/modules/surf"
+)
+
+// seoPluginID is the SEO plugin. The demo mounts it once the plugin is
+// tagged; until then these checks skip, and they import no SEO package.
+const seoPluginID = "golem15.seo"
+
+// skipUnlessSEOMounted skips a test while summer.yaml does not list the
+// SEO plugin; the phase gate reads the NOT MOUNTED line.
+func skipUnlessSEOMounted(t *testing.T) {
+ t.Helper()
+ if !slices.Contains(PluginIDs, seoPluginID) {
+ t.Skip("NOT MOUNTED " + seoPluginID)
+ }
+}
+
+// tabOrder lists a form schema's tabs in the order the admin shows them
+// (first appearance; fields without a tab are the form's outside area).
+func tabOrder(fields []struct {
+ Name string `json:"name"`
+ Tab string `json:"tab"`
+}) []string {
+ var out []string
+ for _, f := range fields {
+ if f.Tab != "" && !slices.Contains(out, f.Tab) {
+ out = append(out, f.Tab)
+ }
+ }
+ return out
+}
+
+// TestSEOTabMounted checks the SEO tab on the Journal forms (SC-2): posts,
+// categories and tags get the SEO tab last, and the post form keeps its
+// own tabs first (Edit, Manage, Categories, SEO).
+func TestSEOTabMounted(t *testing.T) {
+ skipUnlessSEOMounted(t)
+ demo := bootDemo(t, nil, nil)
+ h, err := surf.Assemble(demo.App, demo.Plugins)
+ if err != nil {
+ t.Fatalf("surf.Assemble: %v", err)
+ }
+ prefix, err := cabana.AdminPrefix(demo.App)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var adminID uint
+ stamp := strconv.FormatInt(time.Now().UnixNano(), 36)
+ if err := demo.DB.Raw(`INSERT INTO backend_users (login, email, password, first_name, last_name, is_activated, is_superuser, created_at, updated_at)
+VALUES (?, ?, 'x', 'Seo', 'Check', TRUE, TRUE, NOW(), NOW()) RETURNING id`, "seo-"+stamp, "seo-"+stamp+"@example.test").Scan(&adminID).Error; err != nil || adminID == 0 {
+ t.Fatalf("backend user: id=%d err=%v", adminID, err)
+ }
+ token, _, err := bouncer.MintAudience(demotest.AdminJWTSecret, strconv.FormatUint(uint64(adminID), 10), "http://localhost"+prefix+"/api/v1/auth/login", time.Hour, bouncer.AudienceBackend)
+ if err != nil {
+ t.Fatal(err)
+ }
+ schema := func(controller string) []string {
+ t.Helper()
+ req := httptest.NewRequest(http.MethodGet, prefix+"/api/v1/golem15/journal/"+controller+"/schema/form", nil)
+ req.Header.Set("Authorization", "Bearer "+token)
+ req.Header.Set("Accept-Language", "en")
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("%s schema = %d %s", controller, rec.Code, rec.Body.String())
+ }
+ var out struct {
+ Data struct {
+ Fields []struct {
+ Name string `json:"name"`
+ Tab string `json:"tab"`
+ } `json:"fields"`
+ } `json:"data"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
+ t.Fatal(err)
+ }
+ fields := out.Data.Fields
+ if len(fields) == 0 || fields[len(fields)-1].Tab != "SEO" {
+ t.Fatalf("%s: last field %+v, want the SEO tab last", controller, fields)
+ }
+ found := false
+ for _, f := range fields {
+ found = found || f.Name == "seo_title"
+ }
+ if !found {
+ t.Fatalf("%s: no seo_title field", controller)
+ }
+ return tabOrder(fields)
+ }
+ if got, want := schema("posts"), []string{"Edit", "Manage", "Categories", "SEO"}; !slices.Equal(got, want) {
+ t.Fatalf("post tabs = %v, want %v", got, want)
+ }
+ schema("categories")
+ schema("tags")
+}
diff --git a/summer.yaml b/summer.yaml
index 886edfb..13a4d49 100644
--- a/summer.yaml
+++ b/summer.yaml
@@ -13,6 +13,8 @@ plugins:
module: git.golem15.com/golem15/sm-journal-plugin
- id: golem15.sitemap
module: git.golem15.com/golem15/sm-sitemap-plugin
+ - id: golem15.seo
+ module: git.golem15.com/golem15/sm-seo-plugin
- id: golem15.menu
module: git.golem15.com/golem15/sm-menu-plugin
- id: golem15.faq