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:
324
.planning/phases/32-seo-foundation/32-05-demo-mount.patch
Normal file
324
.planning/phases/32-seo-foundation/32-05-demo-mount.patch
Normal 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
|
||||
Reference in New Issue
Block a user