From 7199b26bb77507349f3eeaf12c498ec64ccea89a Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 4 Nov 2026 18:17:26 +0100 Subject: [PATCH] docs(33-13): grzyby patches for the release - sm-grzybyfunkcjonalne-app: Journal tag pages off and author pages at /autor/:author, the sitemap and SEO config, nginx without the root-file locations (P33-49), check-deploy, smoke.sh (companions, route report, page names, seo-pages.csv, Additional addresses, rebuild), smoke-seo.sh, urls.txt, pl as the only enabled locale, seo-settings.yaml with the author templates and page suffix, DEPLOY.md release steps, tests - vue-grzybyfunkcjonalne-app: nuxt.config on the SEO layer options without @nuxtjs/seo keys, no page useSeoMeta, footer sitemap link, title template for pages the CMS does not answer, SEO fixtures from the rehearsal - local wiring note and the deferred findings --- .../33-13-local-wiring.md | 116 ++ .../33-13-sm-grzybyfunkcjonalne-app.patch | 1698 +++++++++++++++++ .../33-13-vue-grzybyfunkcjonalne-app.patch | 1529 +++++++++++++++ .../deferred-items.md | 31 + 4 files changed, 3374 insertions(+) create mode 100644 .planning/phases/33-seo-output-audit-and-content/33-13-local-wiring.md create mode 100644 .planning/phases/33-seo-output-audit-and-content/33-13-sm-grzybyfunkcjonalne-app.patch create mode 100644 .planning/phases/33-seo-output-audit-and-content/33-13-vue-grzybyfunkcjonalne-app.patch diff --git a/.planning/phases/33-seo-output-audit-and-content/33-13-local-wiring.md b/.planning/phases/33-seo-output-audit-and-content/33-13-local-wiring.md new file mode 100644 index 00000000..21714be1 --- /dev/null +++ b/.planning/phases/33-seo-output-audit-and-content/33-13-local-wiring.md @@ -0,0 +1,116 @@ +# 33-13 local wiring (P33-46) + +How grzybyfunkcjonalne.pl was run on the phase 33 stack before any tag, on 2026-11-04. Nothing here is committed in the app repositories: the wiring lives only in the scratch worktrees, and the two patches leave it out (P33-47). + +## Directories + +| What | Path | +|------|------| +| Scratch directory (shared with 33-12) | `/media/nvme/dev/golem15/summercms.io/scratch-33` | +| App worktree, detached at `35d3664` | `/media/nvme/dev/golem15/summercms.io/scratch-33/sm-grzybyfunkcjonalne-app` | +| Nuxt worktree, detached at `e45ffe8` | `/media/nvme/dev/golem15/summercms.io/scratch-33/sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app` | +| The plan's name for the Nuxt worktree | `/media/nvme/dev/golem15/summercms.io/scratch-33/vue-grzybyfunkcjonalne-app`, a symlink to the path above | + +vue-grzybyfunkcjonalne-app is not a top-level sibling: it is the submodule `vue-grzybyfunkcjonalne-app` of sm-grzybyfunkcjonalne-app (`git@git.golem15.com:golem15/vue-grzybyfunkcjonalne-app.git`, git dir `sm-grzybyfunkcjonalne-app/.git/modules/vue-grzybyfunkcjonalne-app`). Its worktree sits at the submodule path inside the app worktree, because `scripts/build.sh` builds `$ROOT/vue-grzybyfunkcjonalne-app` and git refuses a symlink at a submodule path (`expected submodule path ... not to be a symbolic link`). + +## Commands + +```sh +S=/media/nvme/dev/golem15/summercms.io/summercms +SC=/media/nvme/dev/golem15/summercms.io/scratch-33 +APP=$S/sm-grzybyfunkcjonalne-app +W=$SC/sm-grzybyfunkcjonalne-app + +mkdir -p $SC +git -C $APP worktree add --detach $W HEAD # 35d3664 +git -C $APP/vue-grzybyfunkcjonalne-app worktree add --detach $SC/vue-grzybyfunkcjonalne-app HEAD # e45ffe8 +rmdir $W/vue-grzybyfunkcjonalne-app +git -C $APP/vue-grzybyfunkcjonalne-app worktree move $SC/vue-grzybyfunkcjonalne-app $W/vue-grzybyfunkcjonalne-app +ln -s $W/vue-grzybyfunkcjonalne-app $SC/vue-grzybyfunkcjonalne-app + +# Go: the sibling plugin checkouts at their committed HEADs, copied into the +# (uninitialised) submodule paths; a symlink there breaks git status. +for p in faq journal menu seo sitemap translate user; do + rmdir $W/plugins/golem15/$p && mkdir $W/plugins/golem15/$p + git -C $S/sm-$p-plugin archive HEAD | tar -x -C $W/plugins/golem15/$p +done +sed -i "s#^replace git.golem15.com/golem15/summercms => ../summercms.go#replace git.golem15.com/golem15/summercms => $S/summercms.go#" $W/go.work +# summer build records the copies as local sources (new descriptions, no +# versions): plugins.gen.go drifts by wiring only, so build.sh's drift +# check is told to look away in this worktree's index. 33-14 regenerates +# plugins.gen.go after the real submodule bumps. +git -C $W update-index --assume-unchanged plugins.gen.go + +# Nuxt: the four layers linked to the sibling checkouts. +cat >> $W/vue-grzybyfunkcjonalne-app/pnpm-workspace.yaml < $S/summercms.go/.planning/phases/33-seo-output-audit-and-content/33-13-sm-grzybyfunkcjonalne-app.patch +cd $W/vue-grzybyfunkcjonalne-app && git add -N tests/fixtures/api/seo +git diff --binary -- . ':(exclude)pnpm-lock.yaml' ':(exclude)pnpm-workspace.yaml' \ + > $S/summercms.go/.planning/phases/33-seo-output-audit-and-content/33-13-vue-grzybyfunkcjonalne-app.patch +git -C $APP apply --check && git -C $APP/vue-grzybyfunkcjonalne-app apply --check # both pass +``` + +Left out on purpose: `go.work` (framework replace), `plugins.gen.go` (wiring drift), the submodule pointers, `pnpm-lock.yaml` and `pnpm-workspace.yaml` (link overrides). The pin bumps (go.mod requires, submodule pointers, `package.json` layer tags, the lockfile) are 33-14's. + +## Cleanup (after Task 4 or the deferral) + +```sh +unlink $SC/vue-grzybyfunkcjonalne-app # the symlink +git -C $APP/vue-grzybyfunkcjonalne-app worktree remove --force $W/vue-grzybyfunkcjonalne-app +git -C $APP worktree remove --force $W +git -C $APP worktree list; git -C $APP/vue-grzybyfunkcjonalne-app worktree list +``` + +`--force` because both worktrees hold the uncommitted wiring and patch sources; the patches are committed in summercms.go first. diff --git a/.planning/phases/33-seo-output-audit-and-content/33-13-sm-grzybyfunkcjonalne-app.patch b/.planning/phases/33-seo-output-audit-and-content/33-13-sm-grzybyfunkcjonalne-app.patch new file mode 100644 index 00000000..dc06f799 --- /dev/null +++ b/.planning/phases/33-seo-output-audit-and-content/33-13-sm-grzybyfunkcjonalne-app.patch @@ -0,0 +1,1698 @@ +diff --git a/DEPLOY.md b/DEPLOY.md +index a6e2b43..84fe958 100644 +--- a/DEPLOY.md ++++ b/DEPLOY.md +@@ -8,17 +8,20 @@ How to build, upload, install, migrate, check, release and roll back the SummerC + internet ──443──> nginx vhost uat.grzybyfunkcjonalne.pl (TLS, UAT basic auth, security headers, /backend 404, GET/HEAD only to Nuxt) + │ + ├── /gfadmin, /_journal/, /_menu/, /_faq/, +- │ = /sitemap.xml, = /sitemap.xsl, +- │ = /robots.txt, = /_seo/api/v1/resolve ──> 127.0.0.1:8096 grzybyfunkcjonalne-go (supervisor, user grzybyfunkcjonalne) ++ │ = /_seo/api/v1/resolve, = /mcp ──────────> 127.0.0.1:8096 grzybyfunkcjonalne-go (supervisor, user grzybyfunkcjonalne) + │ ├──> PostgreSQL 17, database grzybyfunkcjonalne (ICU pl-PL) + │ └──> 127.0.0.1:8128 grzybyfunkcjonalne-typesense (post search; no nginx location) + ├── /storage/app/{uploads/public,media}/*.{jpg,jpeg,png,gif,webp} ──> disk: /srv/grzybyfunkcjonalne/go/storage +- └── everything else ──> 127.0.0.1:3016 grzybyfunkcjonalne-nuxt (supervisor, user grzybyfunkcjonalne) +- └──> NUXT_{JOURNAL,MENU,FAQ,SEO}_API_BASE=http://127.0.0.1:8096/_{journal,menu,faq,seo}/api/v1 ++ └── everything else, the root files included ──> 127.0.0.1:3016 grzybyfunkcjonalne-nuxt (supervisor, user grzybyfunkcjonalne) ++ ├──> NUXT_{JOURNAL,MENU,FAQ,SEO}_API_BASE=http://127.0.0.1:8096/_{journal,menu,faq,seo}/api/v1 ++ └──> NUXT_SEO_BACKEND_BASE=http://127.0.0.1:8096: /robots.txt, /llms.txt, /sitemap.xml and ++ /_sitemap/files/sitemap--.xml, served at the site root by vue-seo-layer + + grzybyfunkcjonalne-go ──POST, loopback, bearer token──> 127.0.0.1:3016/api/__site__/cache/purge + ``` + ++The root files (robots.txt, the sitemap index `/sitemap.xml` and its `/sitemap--.xml` files, llms.txt and the search engine verification files) reach visitors through Nuxt: its SEO layer reads them from Go on loopback, so nginx has no location for them, and Go's `/_sitemap/` and the rest of `/_seo/` stay off the public host. ++ + Nuxt caches pages (Nitro `swr`). After an admin save of a Journal post or category commits, the Go binary posts to the Nuxt server's purge route on loopback with the shared token, and Nuxt drops every cached page, so the edit shows on the next view. The vhost passes only GET and HEAD to Nuxt, so the route is not reachable from outside. + + The live site (`grzybyfunkcjonalne.pl`: its nginx vhost, php-fpm, the MySQL database `grzyby` and `/srv/http/grzybyfunkcjonalne.pl`) is only read: the migration copies files out of its storage directory and runs SELECT queries on its database. It stays the rollback. +@@ -44,6 +47,7 @@ The live site (`grzybyfunkcjonalne.pl`: its nginx vhost, php-fpm, the MySQL data + | PHP database | MySQL `grzyby`; post and category morph types `Golem15\Journal\Models\Post` and `Golem15\Journal\Models\Category`; translate tables `winter_translate_*` | + | Admin | `https://uat.grzybyfunkcjonalne.pl/gfadmin`, login `info@golem15.com` (the lower-cased email), author slug `admin` (`/autor/admin`) | + | /tmp | tmpfs (a separate filesystem): uploads must not be written through it, hence `no_tmp_dir=true` in `config/storage.yaml` | ++| Binary | Since the Journal's WebP thumbnails (SummerCMS 0.6.1, `gen2brain/webp` through `purego`), `scripts/build.sh` produces a binary linked dynamically against glibc although `CGO_ENABLED=0`: it needs `ld-linux-x86-64.so.2`, `libc.so.6`, `libdl.so.2` and `libpthread.so.0` (no versioned symbols), which a Debian server has. `libwebp` installed on the server enables the faster encoder; without it the slower built-in one runs. `go build -tags nodynamic` would give a static binary with the built-in encoder only. | + + ## Prerequisites + +@@ -86,15 +90,17 @@ A local `.env` is never rsynced. Compare `sha256sum` of the uploaded binary with + - `SUMMER_ADMIN__JWT__SECRET` and `SUMMER_GOLEM15__USER__JWT__SECRET`: two different values from `head -c 32 /dev/urandom | base64`. + - `SUMMER_ENV=staging` (production after the cutover). The supervisor program sets no environment, so the CLI commands and `serve` read the same value. + - `SUMMER_URL=https://uat.grzybyfunkcjonalne.pl` (the Journal RSS links) and `SUMMER_APP__URL=https://uat.grzybyfunkcjonalne.pl` (`app.url`: the MCP endpoint will not boot without it, see "MCP"). +- - `SUMMER_GOLEM15__SEO__SITE_URL`: the same origin as `NUXT_SITE_URL`. ++ - `SUMMER_GOLEM15__SEO__SITE_URL`: the public origin (`https://grzybyfunkcjonalne.pl`), for canonical, social and hreflang URLs and robots.txt's `Sitemap:` line. + - `SUMMER_GOLEM15__SEO__NOTFOUND_TOKEN`: written by the steps in "SEO (golem15.seo)". + - `SUMMER_SITE_CACHE__PURGE_URL=http://127.0.0.1:3016/api/__site__/cache/purge` and `SUMMER_SITE_CACHE__PURGE_TOKEN`: the site cache purge. + - `SUMMER_SEARCH__TYPESENSE__API_KEY`: written by the steps in "Search (Typesense)"; leave it out until then. + - The purge token: one value, generated on the server and never printed, in both files: `T=$(head -c 32 /dev/urandom | base64 | tr '+/' '-_' | tr -d '=\n')`, then append `SUMMER_SITE_CACHE__PURGE_TOKEN=$T` to `go/.env` and `NUXT_CACHE_PURGE_TOKEN=$T` to `nuxt/.env` with `printf`. Never `NUXT_SITE_CACHE_PURGE_TOKEN`: nuxt-site-config copies every `NUXT_SITE_*` variable into the site config, which goes out in every page's payload. ++- `go/.env` also: `SUMMER_GOLEM15__SITEMAP__SITE_URL`, the same origin as `SUMMER_GOLEM15__SEO__SITE_URL` (every sitemap `` starts with it). + - `nuxt/.env`: +- - `NUXT_SITE_URL=https://uat.grzybyfunkcjonalne.pl`. +- - `NUXT_SITE_INDEXABLE=false`. ++ - `NUXT_SEO_API_BASE=http://127.0.0.1:8096/_seo/api/v1` and `NUXT_SEO_BACKEND_BASE=http://127.0.0.1:8096` (the defaults, written out). ++ - `NUXT_SEO_NOTFOUND_TOKEN`: written by the steps in "SEO (golem15.seo)". + - `NUXT_PUBLIC_GA_ID` empty. ++ - No `NUXT_SITE_*` line: the app has no `@nuxtjs/seo` since vue-journal-layer 0.8, so `NUXT_SITE_URL` and `NUXT_SITE_INDEXABLE` are not read; staging noindex comes from `SUMMER_ENV=staging`. + + **Schema and admin.** As `grzybyfunkcjonalne` in `/srv/grzybyfunkcjonalne/go` (the binary reads `config/` and `.env` from its working directory): + +@@ -196,6 +202,7 @@ The UAT gate asks for credentials from everywhere except the server itself, so t + ```sh + C=$(mktemp -d); printf 'resolve = uat.grzybyfunkcjonalne.pl:443:127.0.0.1\n' > "$C/.curlrc" + cd /srv/grzybyfunkcjonalne/go && CURL_HOME="$C" scripts/smoke.sh --url https://uat.grzybyfunkcjonalne.pl ++CURL_HOME="$C" scripts/smoke-seo.sh https://uat.grzybyfunkcjonalne.pl # ends "smoke-seo: ok" + rm -rf "$C" + ``` + +@@ -205,7 +212,11 @@ It checks every line of `scripts/urls.txt`, including the `remote` lines that on + - pagination; + - static pages; + - negative cases; +-- images from disk. ++- images from disk; ++- the sitemap index and its files, robots.txt and the HTML sitemap through Nuxt; ++- the static pages' titles and descriptions from the CMS. ++ ++`scripts/smoke-seo.sh` then checks the SEO output: one H1, one canonical and one JSON-LD graph per page, the post breadcrumbs through the category, and every sitemap file (each address once, with lastmod, no `/en/` address, no tag file, no stylesheet). + + The 2026-10-09 run ended `smoke: ok (98 urls)`. The same session also checked: + +@@ -359,7 +370,7 @@ print("szukaj taken" if "szukaj" in slugs(json.load(sys.stdin)["data"]) else "ok + + ## SEO (golem15.seo) + +-The Go SEO plugin (`plugins/golem15/seo`, `sm-seo-plugin`) owns the site's SEO: an SEO tab on Journal posts, categories and tags, URL rules, redirects, the 404 log, robots.txt with AI bot switches, settings and history, all under **SEO** and Settings → **SEO settings** in `/gfadmin`. The Nuxt side is `vue-seo-layer`: for every page it asks `GET /_seo/api/v1/resolve?url=…` (SSR on loopback, client-side navigations through nginx), answers the plugin's redirects (301/302) and 410s, and sets the title, description, canonical, robots, Open Graph and X tags from the answer, over the pages' own. Pages the plugin does not know (listings, search, author and static pages without a URL rule) keep the head Nuxt renders. Each 404 page view is reported to the 404 log. nginx routes `/robots.txt` and exactly `/_seo/api/v1/resolve` to Go; the management API under `/_seo/api/v1` stays off the public host (`scripts/check-deploy.sh` fails otherwise). ++The Go SEO plugin (`plugins/golem15/seo`, `sm-seo-plugin`) owns the site's SEO: an SEO tab on Journal posts, categories and authors, the page registry (SEO › **Pages**), URL rules, redirects, the 404 log, robots.txt with AI bot switches and llms.txt, structured data, breadcrumbs, the site audit, settings and history, all under **SEO** and Settings → **SEO settings** in `/gfadmin`. The Nuxt side is `vue-seo-layer`, which the Journal, menu and FAQ layers extend: for every page it asks `GET /_seo/api/v1/resolve?url=…` (SSR on loopback, client-side navigations through nginx), answers the plugin's redirects (301/302) and 410s, and sets the title, description, canonical, robots, Open Graph and X tags and one JSON-LD graph from the answer; the pages set no head of their own. Pages the plugin does not answer for (search) get the app's title template and the site description. Each 404 page view is reported to the 404 log, and at start the Nuxt server reports its static pages (`/`, `/blog`, `/kontakt`, `/trwaja-prace`) to SEO › Pages (the Nuxt log says `[vue-seo-layer] route report: 4 routes sent`). It serves robots.txt, the sitemaps and llms.txt from Go. nginx routes exactly `/_seo/api/v1/resolve` to Go; the management API under `/_seo/api/v1` stays off the public host (`scripts/check-deploy.sh` fails otherwise). + + **Staging lock.** With `SUMMER_ENV=staging` in `go/.env`, every resolve answer is `noindex, nofollow` and `/robots.txt` is `User-agent: *` / `Disallow: /`, whatever is stored. The cutover sets `SUMMER_ENV=production`. + +@@ -370,7 +381,7 @@ On the server, once (as `grzybyfunkcjonalne`): + ```sh + cd /srv/grzybyfunkcjonalne + umask 077 +-grep -q '^SUMMER_GOLEM15__SEO__SITE_URL=' go/.env || printf 'SUMMER_GOLEM15__SEO__SITE_URL=%s\n' 'https://grzybyfunkcjonalne.pl' >> go/.env # = NUXT_SITE_URL ++grep -q '^SUMMER_GOLEM15__SEO__SITE_URL=' go/.env || printf 'SUMMER_GOLEM15__SEO__SITE_URL=%s\n' 'https://grzybyfunkcjonalne.pl' >> go/.env + T=$(head -c 32 /dev/urandom | base64 | tr '+/' '-_' | tr -d '=\n') + sed -i '/^SUMMER_GOLEM15__SEO__NOTFOUND_TOKEN=/d' go/.env + sed -i '/^NUXT_SEO_NOTFOUND_TOKEN=/d' nuxt/.env +@@ -393,9 +404,9 @@ curl -s 'http://127.0.0.1:8096/_seo/api/v1/resolve?url=%2Fcordyceps' | python3 - + curl -s -o /dev/null -w '%{http_code}\n' --resolve uat.grzybyfunkcjonalne.pl:443:127.0.0.1 -H 'Accept: text/html' https://uat.grzybyfunkcjonalne.pl/seo-404-check # 404, then in SEO → 404 log + ``` + +-`scripts/urls.txt` covers robots.txt, the resolve endpoint and the plugin's head on the home page, a post and a category. ++`scripts/urls.txt` covers robots.txt, the resolve endpoint and the plugin's head on the home page, the static pages, a post and a category; `scripts/smoke-seo.sh` the rest of the SEO output. + +-**Rollback.** `NUXT_PUBLIC_SEO_ENABLED=false` in `nuxt/.env` and a Nuxt restart turn the layer off (the pages render their own head; no redirects or 404 reports). To serve robots.txt from Nuxt again, revert the site app commit that set `robots: { robotsTxt: false }` and remove `location = /robots.txt` from the vhost. ++**Rollback.** `NUXT_PUBLIC_SEO_ENABLED=false` in `nuxt/.env` and a Nuxt restart turn the layer off: no redirects, 404 reports or head, and no root files either, so robots.txt and the sitemaps then answer 404 until the previous release is back ("Rollback"). + + ## MCP + +@@ -417,7 +428,7 @@ The binary serves an MCP endpoint at `https://uat.grzybyfunkcjonalne.pl/mcp` (th + + Since Phase 17 the menu, the homepage FAQ and the sitemap come from three Go plugins (`plugins/golem15/{menu,faq,sitemap}`, the repositories `sm-menu-plugin`, `sm-faq-plugin` and `sm-sitemap-plugin`) instead of the Nuxt app. Editors manage them in `/gfadmin`: Menus and Menu entries, FAQ, and Settings → Sitemap and Sitemap settings. The site renders the menu `main-menu` (the PHP site's menu id 1) through `vue-menu-layer` and the FAQ `what-mushrooms-can-give-you` through `vue-faq-layer`; both layers fetch on the server from `127.0.0.1:8096`, and nginx routes the browser's `/_menu/` and `/_faq/` requests to Go. + +-**Sitemap.** `/sitemap.xml` and `/sitemap.xsl` are the Go sitemap plugin's, in the PHP plugin's format; nginx routes exactly these two paths to Go and the Nuxt sitemap module is off (Phase 16 D-13 reversed for the sitemap only; meta, Open Graph and canonical stay on Nuxt; since the SEO plugin, robots.txt is Go's and names `/sitemap.xml`). The plugin builds absolute URLs from `Host` and `X-Forwarded-Proto` (trusted from 127.0.0.1); Sitemap settings → `frontend_url` would rewrite them to another origin, and the priority tiers are on by default (`config/golem15.yaml` `sitemap.priority_tiers` overrides them). The PHP site never saved sitemap settings, so the plugin keeps its defaults. ++**Sitemap.** Since Phase 33 the Go sitemap plugin serves the per-type format: `/sitemap.xml` is an index of `sitemap-posts-1.xml`, `sitemap-categories-1.xml`, `sitemap-authors-1.xml`, `sitemap-pages-1.xml` and `sitemap-images-1.xml`, each address with its real lastmod (no priority or change frequency), and `/sitemap.xsl` answers 404. The files are written by a rebuild (the worker in `serve` queues one after content changes and runs one nightly at 03:00; `bin/grzybyfunkcjonalne sitemap:rebuild` runs one at once) and served from the table, never per request. Every `` starts with the Site address setting, else `golem15.sitemap.site_url` (`SUMMER_GOLEM15__SITEMAP__SITE_URL`), else `app.url`; never the request's Host. Nuxt serves the index and the files at the root through the SEO layer, and robots.txt names the index. Editors manage the content types, the file size, Additional addresses, images, IndexNow (off) and the HTML sitemap (`/mapa-strony`, linked from the footer) under Settings → **Sitemaps**. The PHP definition's url items (`/` and the `blog` CMS page as `/blog`) became Additional addresses; since SEO › Pages lists both, they are removed in "SEO pages and sitemaps (Phase 33)". + + **Export.** Read-only, on the PHP database, as in `scripts/php-export/README.md` section 4: `faqs.jsonl`, `menus.jsonl`, `entries.jsonl` and `sitemap-definitions.jsonl` in `/srv/grzybyfunkcjonalne/import/companions/`. The FAQ query joins `winter_translate_locales` on this site. On 2026-10-12 they held one menu (`main-menu`), 8 entries (7 `link`, 1 `blog_categories`), one FAQ (`what-mushrooms-can-give-you`, `pl`, 16 questions) and one sitemap definition (theme `arsha`: a `url` item `/`, a `cms-page` item `blog`, `all-journal-posts`, `all-journal-categories`). + +@@ -439,15 +450,41 @@ The maps this site needs: + | menu `link` | `url` | built into `menu:import-winter` | + | menu `blog_categories` | `all-journal-categories` (Journal; children at `/` through `config/menu_items.yaml`) | built in | + | menu `page` | dropped | built in (none on this site) | +-| sitemap `url` | kept | built in | +-| sitemap `cms-page` `blog` | `url` `/blog` | `--page-url blog=/blog` | +-| sitemap `all-journal-posts`, `all-journal-categories` | kept (same keys); their `cmsPage` is dropped and the host patterns apply | built in | ++| sitemap `url` | an Additional address | built in | ++| sitemap `cms-page` `blog` | the Additional address `/blog` | `--page-url blog=/blog` | ++| sitemap `all-journal-posts`, `all-journal-categories` | dropped: the posts and categories files list every published post and category at the host patterns | built in | + + No `--map` is needed. The six `link` entries under parent 1 (Reishi … Lion's Mane, relative URLs `rodzaje/`) belong to an entry that no longer exists, so PHP never rendered them; the import drops them and reports it. + +-**Edits and caches.** Menu and FAQ saves, deletes and menu-entry reorders purge the Nuxt page cache like Journal saves, so edits show on the next view. The sitemap is generated per request (`Cache-Control: no-cache, private`). ++**Edits and caches.** Menu and FAQ saves, deletes and menu-entry reorders purge the Nuxt page cache like Journal saves, so edits show on the next view. The sitemap files change with the next rebuild (`Cache-Control: no-cache, private`). ++ ++**Rollback** of the menu and FAQ only: remove the two locations (`^~ /_menu/`, `^~ /_faq/`) from the vhost and reload nginx; the previous site app release brings back the static menu and FAQ. The sitemap has no separate rollback since Phase 33: it comes back with the previous release ("Rollback"). ++ ++## SEO pages and sitemaps (Phase 33) + +-**Rollback** of the companions only: remove the four locations (`= /sitemap.xml`, `= /sitemap.xsl`, `^~ /_menu/`, `^~ /_faq/`) from the vhost and reload nginx, and set `sitemap: { enabled: true }` again in the site app's `nuxt.config.ts` (the previous site app commit), so Nuxt serves its own sitemap; the previous site app release also brings back the static menu and FAQ. ++The release that brings SummerCMS 0.6.1 (sm-seo-plugin, sm-sitemap-plugin and sm-journal-plugin with the per-type sitemaps, the page registry and the SEO layer for the site app). It changes data, so it runs once, in this order, as `grzybyfunkcjonalne` in `/srv/grzybyfunkcjonalne/go` unless a step says otherwise. Never print a token. ++ ++1. **Backup** (root): `sudo -u postgres pg_dump -Fc grzybyfunkcjonalne | sudo tee /root/grzybyfunkcjonalne-pre-seo-.dump >/dev/null`, and keep `bin/grzybyfunkcjonalne.prev` and a copy of `nuxt/.output` (`cp -a ../nuxt/.output ../nuxt/.output.prev`). ++2. **Upload** as in "Release": the binary through `.new`, `config/`, `scripts/`, `nuxt/.output`, and `deploy/` with the new `deploy/seo-pages.csv` and both vhosts. ++3. **Environment.** In `go/.env` add `SUMMER_GOLEM15__SITEMAP__SITE_URL` with the value of `SUMMER_GOLEM15__SEO__SITE_URL` (`grep -q` first, then `printf`, as in "SEO (golem15.seo)"). In `nuxt/.env` add `NUXT_SEO_API_BASE=http://127.0.0.1:8096/_seo/api/v1` and `NUXT_SEO_BACKEND_BASE=http://127.0.0.1:8096`; `NUXT_SEO_NOTFOUND_TOKEN` is already there and now also carries the route report. Check by name only: `grep -c '^NUXT_SEO_NOTFOUND_TOKEN=.' ../nuxt/.env` and `grep -c '^SUMMER_GOLEM15__SEO__NOTFOUND_TOKEN=.' .env` both print 1. The `NUXT_SITE_*` lines can go. ++4. **Migrate:** `bin/grzybyfunkcjonalne migrate`. The sitemap migration `202610280002` copies the stored Phase 17 definition's url items (`/`, `/blog`) into the Additional addresses; the SEO migrations up to `202610280007` and the Journal's `202610280002` add tables and columns with defaults. ++5. **Polish only.** The site serves no other locale, but the translate plugin's `en` row is still enabled, which would give every page an `/en/` hreflang link and every record an `/en/` sitemap address (both 404). Check, then switch it off (root): ++ ```sh ++ sudo -u postgres psql -d grzybyfunkcjonalne -Atc "SELECT code, is_default, is_enabled FROM golem15_translate_locales ORDER BY code" ++ sudo -u postgres psql -d grzybyfunkcjonalne -c "UPDATE golem15_translate_locales SET is_enabled = (code = 'pl')" ++ ``` ++ The same switch is Settings → Locales in `/gfadmin`. `pl` stays the default; no translation row is deleted. ++6. **SEO settings:** `bin/grzybyfunkcjonalne grzyby:seo-settings --file ../deploy/seo-settings.yaml --dry-run` must list only `settings.page_title_suffix` and the two `types.journal-author.*` templates. Any other line is a value an editor changed in `/gfadmin` that the file would put back: stop and bring the file in line first. Then run it without `--dry-run`. ++7. **Restart** both programs (`sudo supervisorctl restart grzybyfunkcjonalne-go grzybyfunkcjonalne-nuxt`) and wait for `[vue-seo-layer] route report: 4 routes sent` in `/var/log/supervisor/grzybyfunkcjonalne-nuxt.log`. SEO › Pages then lists `/`, `/blog`, `/kontakt` and `/trwaja-prace`. ++8. **Page names** (SEO › Pages, or `seo_update_page` over MCP): the route report names a page after its last path segment. Rename `/blog` to `Artykuły` and `/trwaja-prace` to `Trwają prace`: the name is the page's `%title%` (`Artykuły | Grzyby Funkcjonalne`, `Artykuły · Strona 2 | …` on page 2) and shows in the HTML sitemap, the breadcrumbs and the structured data. ++9. **Page descriptions:** SEO › Import and export, dataset "SEO of records (by URL)", file `deploy/seo-pages.csv` (or `seo_import_csv` over MCP). The preview must show four rows and no refused one; apply. The file holds the home title and every static page's description and social title as the PHP site rendered them. ++10. **Additional addresses** (Settings → Sitemaps, tab Additional addresses, or `sitemap_update_settings` with `extra_urls: []`): remove `/` and `/blog`, which SEO › Pages now lists in the same pages file; otherwise both are listed twice. The save queues a rebuild. ++11. **Rebuild** at once: `bin/grzybyfunkcjonalne sitemap:rebuild` ends `Rebuilt the sitemaps: N addresses in N files`. ++12. **Smoke** as in "Smoke" (`scripts/smoke.sh --url` and `scripts/smoke-seo.sh`), with `scripts/urls-production.txt` on production. Then empty the 404 log of the smoke's entries, resubmit `https://grzybyfunkcjonalne.pl/sitemap.xml` in Search Console (the same address, now an index) and check that the HTML sitemap `/mapa-strony` lists the pages. ++ ++`scripts/smoke.sh` rehearses steps 4 to 11 locally (steps 8 to 10 through the management APIs with a temporary token of scope `*`, revoked at the end; the SEO and sitemap plugins declare no token scope of their own). ++ ++**Rollback** of this release: stop both programs, restore the database dump of step 1 (`pg_restore --clean --if-exists`), move `bin/grzybyfunkcjonalne.prev` and `nuxt/.output.prev` back, reinstall the previous vhost (with `= /sitemap.xml`, `= /sitemap.xsl` and `= /robots.txt` to Go), reload nginx and start both programs. The previous binary does not know the new tables and columns, so the database must go back with it. + + ## Rollback + +diff --git a/README.md b/README.md +index 2f96d4d..130d2f8 100644 +--- a/README.md ++++ b/README.md +@@ -2,10 +2,10 @@ + + grzybyfunkcjonalne.pl on SummerCMS. The site runs as two processes: + +-- **Go** (`bin/grzybyfunkcjonalne`): a headless SummerCMS host with the `golem15.user`, `golem15.translate`, `golem15.journal`, `golem15.sitemap`, `golem15.menu` and `golem15.faq` plugins. It serves the admin at `/gfadmin`, the Journal API at `/_journal/api/v1`, the menu and FAQ APIs at `/_menu/api/v1` and `/_faq/api/v1`, and `/sitemap.xml` with `/sitemap.xsl`. +-- **Nuxt** (`vue-grzybyfunkcjonalne-app`): the server-rendered site. It renders every public page from the Journal API, using the shared `vue-journal-layer`, the header menu with `vue-menu-layer` and the homepage FAQ with `vue-faq-layer`. ++- **Go** (`bin/grzybyfunkcjonalne`): a headless SummerCMS host with the `golem15.user`, `golem15.translate`, `golem15.journal`, `golem15.seo`, `golem15.sitemap`, `golem15.menu` and `golem15.faq` plugins. It serves the admin at `/gfadmin`, the Journal API at `/_journal/api/v1`, the menu and FAQ APIs at `/_menu/api/v1` and `/_faq/api/v1`, the SEO plugin's API at `/_seo/api/v1` with `/robots.txt` and `/llms.txt`, and the sitemap index `/sitemap.xml` with its files at `/_sitemap/files/`. ++- **Nuxt** (`vue-grzybyfunkcjonalne-app`): the server-rendered site. It renders every public page from the Journal API, using the shared `vue-journal-layer`, the header menu with `vue-menu-layer` and the homepage FAQ with `vue-faq-layer`. All three extend `vue-seo-layer`, which takes every page's head from the SEO plugin and serves the root files (robots.txt, `/sitemap.xml` and its `/sitemap--.xml` files, llms.txt) from Go on loopback. + +-nginx sends `/gfadmin`, `/_journal/`, `/_menu/`, `/_faq/`, `/sitemap.xml` and `/sitemap.xsl` to Go, serves images under `/storage/app/{uploads/public,media}` from disk, and sends everything else to Nuxt. The menu, FAQ and sitemap and their PHP data: [`DEPLOY.md`](DEPLOY.md), "Blog companions". ++nginx sends `/gfadmin`, `/_journal/`, `/_menu/`, `/_faq/`, exactly `/_seo/api/v1/resolve` and the MCP paths to Go, serves images under `/storage/app/{uploads/public,media}` from disk, and sends everything else to Nuxt, the root files included. The menu, FAQ and sitemap and their PHP data: [`DEPLOY.md`](DEPLOY.md), "Blog companions". + + The module is `git.golem15.com/golem15/sm-grzybyfunkcjonalne-app`. It requires the framework, `git.golem15.com/golem15/summercms`, through a local `replace` to `../summercms.go`. + +@@ -15,7 +15,7 @@ The module is `git.golem15.com/golem15/sm-grzybyfunkcjonalne-app`. It requires t + |------|------| + | `vue-grzybyfunkcjonalne-app/` | Submodule `golem15/vue-grzybyfunkcjonalne-app` (private, SSH): the Nuxt 4 SSR site. It extends `vue-journal-layer` from a git tag. | + | `plugins/golem15/{user,translate,journal}/` | Submodules `golem15/sm-user-plugin`, `sm-translate-plugin` and `sm-journal-plugin`: users, translatable content and the journal, with their admin. | +-| `plugins/golem15/{sitemap,menu,faq}/` | Submodules `golem15/sm-sitemap-plugin`, `sm-menu-plugin` and `sm-faq-plugin`: the sitemap, menus and FAQs, with their admin. `config/menu_items.yaml` sets the Journal URL patterns (`/blog/:slug`, `/:slug`) the menu and sitemap build. | ++| `plugins/golem15/{sitemap,menu,faq}/` | Submodules `golem15/sm-sitemap-plugin`, `sm-menu-plugin` and `sm-faq-plugin`: the sitemap, menus and FAQs, with their admin. `config/menu_items.yaml` sets the Journal URL patterns (`/blog/:slug`, `/:slug`, `/autor/:author`) the menu, the sitemaps and the SEO plugin build; `config/golem15.journal.yaml` turns tag pages off. | + | `plugins/grzyby/` | The app plugin `golem15.grzyby` (an in-repo module). It holds console commands for the migration only and adds no HTTP endpoint. | + | `summer.yaml`, `main.go`, `plugins.gen.go` | The app manifest and the sources `summer build` generates from it (committed; never edit the two `.go` files by hand). | + | `go.work` | Workspace of the app and the plugins. Its `replace` points the framework at `../summercms.go`. | +@@ -74,6 +74,9 @@ The smoke never touches any other database. + | `SMOKE_EXPORT_DIR` | the local PHP journal export | Export directory for the rehearsal | + | `SMOKE_GO_PORT` | `18096` | Go port | + | `SMOKE_NUXT_PORT` | `13016` | Nuxt port | ++| `SMOKE_KEEP` | unset | `1` keeps Go and Nuxt running after a passing rehearsal | ++ ++**`scripts/smoke-seo.sh BASE_URL`** checks the SEO output through the public front: one H1, one canonical and one JSON-LD graph on a post, a category, `/kontakt` and `/`, the post's breadcrumbs through its category, the sitemap index and its posts, categories, authors and pages files (each address once, with lastmod, no `/en/` address), no tag file and no `/sitemap.xsl`. The full rehearsal runs it last; on the server it runs after `smoke.sh --url` (DEPLOY.md, "Smoke"). + + **`scripts/urls.txt`** lists every URL of the live sitemap (71, fetched 2026-10-09), plus pagination, static pages and negative cases. Each line has the form `[remote] STATUS PATH [contains=…] [notcontains=…] [type=…]`. Lines marked `remote` need nginx and are checked only with `--url`. + +@@ -97,7 +100,7 @@ cd /srv/grzybyfunkcjonalne/go + + 1. `migrate`. + 2. It refuses a database that already holds journal posts. +-3. It makes `pl` the only default locale and asserts it. Live post URLs use the Polish slugs, which the import writes to the host columns only when `pl` is the default. ++3. It makes `pl` the only default and the only enabled locale and asserts it. Live post URLs use the Polish slugs, which the import writes to the host columns only when `pl` is the default; an enabled `en` without content would give every page an `/en/` hreflang link and sitemap address that answers 404. + 4. The post import (`journal:import-posts`, dry run first). It runs through `grzyby:import-posts`, because the generated CLI does not open the database for plugin commands. The import reads a copy of the export in which empty `"translations": []` arrays (written by the PHP exporter) become `{}`. + 5. Category names, descriptions and content (`grzyby:import-categories`). + 6. The media library, with rsync. +@@ -120,15 +123,15 @@ Journal post and category content are edited with the Blog Editor (WYSIWYG with + + | Process | Address | Environment file | Template | + |---|---|---|---| +-| Go (`grzybyfunkcjonalne-go`) | `127.0.0.1:8096` | `/srv/grzybyfunkcjonalne/go/.env` | `deploy/env.example` (database DSN, app key, two JWT secrets, `SUMMER_URL`, `SUMMER_APP__URL`, `SUMMER_SITE_CACHE__PURGE_URL`, `SUMMER_SITE_CACHE__PURGE_TOKEN`, `SUMMER_SEARCH__TYPESENSE__API_KEY`, `SUMMER_ENV`, `SUMMER_GOLEM15__SEO__SITE_URL`, `SUMMER_GOLEM15__SEO__NOTFOUND_TOKEN`) | +-| Nuxt (`grzybyfunkcjonalne-nuxt`) | `127.0.0.1:3016` | `/srv/grzybyfunkcjonalne/nuxt/.env` | `deploy/nuxt.env.example` (`NUXT_JOURNAL_API_BASE`, `NUXT_SITE_URL`, `NUXT_SITE_INDEXABLE`, `NUXT_PUBLIC_GA_ID`, `NUXT_CACHE_PURGE_TOKEN`, `NUXT_SEO_NOTFOUND_TOKEN`, `NITRO_HOST`, `NITRO_PORT`) | ++| Go (`grzybyfunkcjonalne-go`) | `127.0.0.1:8096` | `/srv/grzybyfunkcjonalne/go/.env` | `deploy/env.example` (database DSN, app key, two JWT secrets, `SUMMER_URL`, `SUMMER_APP__URL`, `SUMMER_SITE_CACHE__PURGE_URL`, `SUMMER_SITE_CACHE__PURGE_TOKEN`, `SUMMER_SEARCH__TYPESENSE__API_KEY`, `SUMMER_ENV`, `SUMMER_GOLEM15__SEO__SITE_URL`, `SUMMER_GOLEM15__SITEMAP__SITE_URL`, `SUMMER_GOLEM15__SEO__NOTFOUND_TOKEN`, optionally `SUMMER_GOLEM15__SEO__ROUTE_REPORT_TOKEN`) | ++| Nuxt (`grzybyfunkcjonalne-nuxt`) | `127.0.0.1:3016` | `/srv/grzybyfunkcjonalne/nuxt/.env` | `deploy/nuxt.env.example` (`NUXT_JOURNAL_API_BASE`, `NUXT_SEO_API_BASE`, `NUXT_SEO_BACKEND_BASE`, `NUXT_PUBLIC_GA_ID`, `NUXT_CACHE_PURGE_TOKEN`, `NUXT_SEO_NOTFOUND_TOKEN`, optionally `NUXT_SEO_ROUTE_REPORT_TOKEN`, `NITRO_HOST`, `NITRO_PORT`) | + | Typesense (`grzybyfunkcjonalne-typesense`) | `127.0.0.1:8128` (peering `127.0.0.1:8127`) | `/srv/grzybyfunkcjonalne/typesense/typesense.ini` | `deploy/typesense.ini.example` (the API key, the data directory, loopback addresses) | + +-- On staging, `NUXT_SITE_INDEXABLE=false` and `NUXT_PUBLIC_GA_ID` stays empty. Production removes the first and sets `G-MLSV7WJ6W4`. ++- On staging, `NUXT_PUBLIC_GA_ID` stays empty and `SUMMER_ENV=staging` in `go/.env` keeps every page noindex (the SEO plugin's staging lock). Production sets `G-MLSV7WJ6W4`. `NUXT_SITE_URL` and `NUXT_SITE_INDEXABLE` are no longer read (the app has no `@nuxtjs/seo` since `vue-journal-layer` 0.8). + - Set `SUMMER_URL` to the public `https://` origin. Otherwise RSS links come out as `http://`. + - The site cache purge token is one value, generated on the server, in both files (`SUMMER_SITE_CACHE__PURGE_TOKEN` and `NUXT_CACHE_PURGE_TOKEN`; never a `NUXT_SITE_*` name, which nuxt-site-config publishes in the page payload). After an admin save of a Journal post or category, a menu, a menu entry or a FAQ, Go posts to Nuxt's purge route on loopback, so the edit shows on the next page view. + - The Typesense key is one value, generated on the server, in `typesense.ini` and `go/.env` (`SUMMER_SEARCH__TYPESENSE__API_KEY`). `config/search.yaml` holds the rest and no key. +-- The SEO 404 token is one value, generated on the server, in both files (`SUMMER_GOLEM15__SEO__NOTFOUND_TOKEN` and `NUXT_SEO_NOTFOUND_TOKEN`). `SUMMER_ENV=staging` in `go/.env` keeps the SEO plugin's noindex lock on; the supervisor program sets no environment of its own. ++- The SEO 404 token is one value, generated on the server, in both files (`SUMMER_GOLEM15__SEO__NOTFOUND_TOKEN` and `NUXT_SEO_NOTFOUND_TOKEN`); it also carries the Nuxt server's route report to SEO › Pages unless both files set a route report token of their own. `SUMMER_ENV=staging` in `go/.env` keeps the SEO plugin's noindex lock on; the supervisor program sets no environment of its own. + - The `.env` files and `typesense.ini` live only on the server. + + The deploy files are: +@@ -136,7 +139,8 @@ The deploy files are: + - `deploy/nginx/grzybyfunkcjonalne-staging.conf`: the staging vhost on the PHP site's server, next to the untouched Winter vhost. + - `deploy/supervisor/grzybyfunkcjonalne-{go,nuxt,typesense}.conf`: the three supervisor programs. + - `deploy/typesense.ini.example`: the Typesense settings template. +-- `deploy/seo-settings.yaml`: the SEO plugin's settings and robots.txt, written by `grzyby:seo-settings`. ++- `deploy/seo-settings.yaml`: the SEO plugin's settings, the author pages' templates and robots.txt, written by `grzyby:seo-settings`. ++- `deploy/seo-pages.csv`: the static pages' descriptions, social titles and the home title, imported once through the SEO plugin's CSV import (records by URL) after the route report (DEPLOY.md, "SEO pages and sitemaps (Phase 33)"). + + ## Search + +@@ -152,7 +156,7 @@ Inside the container Typesense must listen on `0.0.0.0` for the port mapping; th + + ## SEO + +-The Go SEO plugin (`plugins/golem15/seo`) holds the site's SEO, edited in `/gfadmin` (SEO, and Settings → SEO settings), and the site app extends `vue-seo-layer`, which asks the plugin for every page's redirect decision and head and lets its values win over the pages' own. `robots.txt` is the plugin's (nginx routes it to Go). The settings come from `deploy/seo-settings.yaml` (`bin/grzybyfunkcjonalne grzyby:seo-settings`). Without the plugin's API (the site app's tests, `pnpm smoke:ssr`) the pages render their own head. Outside `SUMMER_ENV=production` the plugin answers noindex everywhere and robots.txt disallows everything. The server steps are in [`DEPLOY.md`](DEPLOY.md), "SEO (golem15.seo)". ++The Go SEO plugin (`plugins/golem15/seo`) holds the site's SEO, edited in `/gfadmin` (SEO, and Settings → SEO settings), and the site app's layers extend `vue-seo-layer`, which takes every page's redirect decision and head from the plugin (one title, canonical and JSON-LD graph, breadcrumbs, pagination links), reports 404s and the static pages (SEO › Pages), and serves robots.txt, the sitemaps and llms.txt from Go. The pages set no head of their own: the static pages' titles and descriptions live in the CMS (SEO › Pages names, `deploy/seo-pages.csv`), pages the CMS does not answer for (search) get the app's title template and the site description. The settings come from `deploy/seo-settings.yaml` (`bin/grzybyfunkcjonalne grzyby:seo-settings`). The site app's tests read the plugin's answers from `tests/fixtures/api/seo/`. Outside `SUMMER_ENV=production` the plugin answers noindex everywhere and robots.txt disallows everything. The server steps are in [`DEPLOY.md`](DEPLOY.md), "SEO (golem15.seo)". + + ## Deploy + +diff --git a/boot_test.go b/boot_test.go +index 2455a16..830249c 100644 +--- a/boot_test.go ++++ b/boot_test.go +@@ -165,27 +165,43 @@ func TestBootUserTranslateJournal(t *testing.T) { + assertGitlink(t, "plugins/golem15/sitemap") + assertGitlink(t, "plugins/golem15/menu") + assertGitlink(t, "plugins/golem15/faq") ++ assertGitlink(t, "plugins/golem15/seo") + assertJournalCORS(t) + } + +-// assertCompanionRoutes checks that the sitemap, menu and FAQ plugins mount +-// their public routes in the assembled router. ++// assertCompanionRoutes checks that the sitemap, SEO, menu and FAQ plugins ++// mount the routes the site's Nuxt server and nginx reach: the sitemap index ++// and its files (vue-seo-layer maps /sitemap--.xml to ++// /_sitemap/files/), the HTML sitemap data, robots.txt, the site answer, ++// the resolve endpoint, the 404 log and the route report of the SEO layer, ++// and the menu and FAQ APIs. The Phase 17 stylesheet /sitemap.xsl is gone. + func assertCompanionRoutes(t *testing.T, routes []surf.RouteInfo) { + t.Helper() +- want := map[string]bool{ +- "/sitemap.xml": false, +- "/sitemap.xsl": false, +- "/_menu/api/v1/menus/{code}": false, +- "/_faq/api/v1/faqs/{slug}": false, ++ type route struct{ method, pattern string } ++ want := map[route]bool{ ++ {http.MethodGet, "/sitemap.xml"}: false, ++ {http.MethodGet, "/_sitemap/files/{file}"}: false, ++ {http.MethodGet, "/_sitemap/api/v1/html"}: false, ++ {http.MethodGet, "/robots.txt"}: false, ++ {http.MethodGet, "/_seo/api/v1/site"}: false, ++ {http.MethodGet, "/_seo/api/v1/resolve"}: false, ++ {http.MethodPost, "/_seo/api/v1/notfound"}: false, ++ {http.MethodPost, "/_seo/api/v1/registry/routes"}: false, ++ {http.MethodGet, "/_menu/api/v1/menus/{code}"}: false, ++ {http.MethodGet, "/_faq/api/v1/faqs/{slug}"}: false, + } + for _, info := range routes { +- if _, ok := want[info.Pattern]; ok && info.Method == http.MethodGet { +- want[info.Pattern] = true ++ if info.Pattern == "/sitemap.xsl" { ++ t.Errorf("assembled routes still carry %s %s", info.Method, info.Pattern) ++ } ++ key := route{info.Method, info.Pattern} ++ if _, ok := want[key]; ok { ++ want[key] = true + } + } +- for pattern, found := range want { ++ for r, found := range want { + if !found { +- t.Errorf("assembled routes missing GET %s", pattern) ++ t.Errorf("assembled routes missing %s %s", r.method, r.pattern) + } + } + } +diff --git a/companions_test.go b/companions_test.go +index d383a9a..8b6aaac 100644 +--- a/companions_test.go ++++ b/companions_test.go +@@ -16,6 +16,7 @@ import ( + "time" + + journalmodels "git.golem15.com/golem15/sm-journal-plugin/models" ++ sitemapmodels "git.golem15.com/golem15/sm-sitemap-plugin/models" + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/bonfire" + "git.golem15.com/golem15/summercms/modules/lagoon" +@@ -42,14 +43,18 @@ const companionsFAQSlug = "what-mushrooms-can-give-you" + // TestCompanionsHost boots the host's seven plugins on Postgres, imports the + // Winter-shaped fixture exports in testdata/companions through the three + // import-winter commands (the order scripts/import-companions.sh runs), and +-// reads the menu, the FAQ and the sitemap through the assembled router: the ++// reads the menu, the FAQ and the sitemaps through the assembled router: the + // PHP blog_categories entry serves the Journal categories at the host +-// pattern /, the page entry is gone, the FAQ answer is sanitized, and +-// the sitemap lists / and /blog/ and nothing under the +-// Journal default /blog/category/. The sitemap answers application/xml and +-// its stylesheet text/xsl without CORS headers, while the menu and FAQ APIs +-// answer CORS preflights and simple requests from any origin, without +-// credentials, as the host's cors.paths say. ++// pattern /, the page entry is gone, the FAQ answer is sanitized. The ++// sitemap definition's url items (/ and the --page-url /blog) land in the ++// sitemap's Additional addresses and its frontend_url in the site address; ++// after sitemap:rebuild /sitemap.xml is an index of per-type files whose ++// addresses use the host patterns (/, /blog/, nothing under ++// the Journal defaults), the pages file holds the Additional addresses, no ++// tag file exists (golem15.journal.tag_pages false) and /sitemap.xsl is ++// gone. The sitemaps answer application/xml without CORS headers, while the ++// menu and FAQ APIs answer CORS preflights and simple requests from any ++// origin, without credentials, as the host's cors.paths say. + func TestCompanionsHost(t *testing.T) { + if testing.Short() { + t.Skip("requires Postgres (" + companionsDSNEnv + " or testcontainers)") +@@ -108,8 +113,10 @@ func TestCompanionsHost(t *testing.T) { + t.Errorf("menu report lacks %q:\n%s", want, menuReport) + } + } +- if !strings.Contains(sitemapReport, "copied as type 'url' with url '/blog'") { +- t.Errorf("sitemap report lacks the /blog page item:\n%s", sitemapReport) ++ for _, want := range []string{"into the additional addresses", "copied as an additional address '/blog'", "the content types list their records themselves"} { ++ if !strings.Contains(sitemapReport, want) { ++ t.Errorf("sitemap report lacks %q:\n%s", want, sitemapReport) ++ } + } + t.Logf("faq:import-winter:\n%s\nmenu:import-winter:\n%s\nsitemap:import-winter:\n%s", faqReport, menuReport, sitemapReport) + +@@ -168,24 +175,42 @@ func TestCompanionsHost(t *testing.T) { + t.Fatalf("faq body = %s", body) + } + +- // Sitemap: application/xml with the Journal URLs at the host patterns. ++ // Sitemap: the definition's url items are the Additional addresses and ++ // its frontend_url the site address. ++ var settings sitemapmodels.Settings ++ if err := gdb.First(&settings).Error; err != nil { ++ t.Fatalf("sitemap settings: %v", err) ++ } ++ var extras []string ++ for _, e := range settings.Extra() { ++ extras = append(extras, e.URL) ++ } ++ if got := strings.Join(extras, " "); got != "/ /blog" { ++ t.Errorf("Additional addresses = %q, want \"/ /blog\"", got) ++ } ++ if settings.SiteURL == nil || *settings.SiteURL != "https://www.example.test" { ++ t.Errorf("sitemap site address = %v, want https://www.example.test", settings.SiteURL) ++ } ++ t.Logf("sitemap:rebuild:\n%s", run("sitemap:rebuild")) ++ ++ // The index names the per-type files on the site address. + rec = get("/sitemap.xml") +- if rec.Code != http.StatusOK || rec.Header().Get("Content-Type") != "application/xml" { ++ if rec.Code != http.StatusOK || rec.Header().Get("Content-Type") != "application/xml" || !strings.Contains(rec.Body.String(), "https://www.example.test/sitemap-posts-1.xml") { ++ t.Errorf("sitemap index is not on the site address:\n%s", rec.Body.String()) + } + if cc := rec.Header().Get("Cache-Control"); cc != "no-cache, private" { + t.Errorf("sitemap Cache-Control = %q", cc) +@@ -193,8 +218,36 @@ func TestCompanionsHost(t *testing.T) { + if rec.Header().Get("Access-Control-Allow-Origin") != "" { + t.Error("the sitemap is outside cors.paths but carries CORS headers") + } +- if xsl := get("/sitemap.xsl"); xsl.Code != http.StatusOK || xsl.Header().Get("Content-Type") != "text/xsl; charset=UTF-8" { +- t.Errorf("GET /sitemap.xsl: %d %q", xsl.Code, xsl.Header().Get("Content-Type")) ++ ++ // Each file, as vue-seo-layer reads it from /_sitemap/files/. ++ listed := map[string][]string{} ++ for file, want := range map[string][]string{ ++ "sitemap-posts-1.xml": {"/blog/hello-grzyby"}, ++ "sitemap-categories-1.xml": {"/reishi", "/chaga"}, ++ "sitemap-pages-1.xml": {"/", "/blog"}, ++ } { ++ rec := get("/_sitemap/files/" + file) ++ if rec.Code != http.StatusOK || rec.Header().Get("Content-Type") != "application/xml" { ++ t.Fatalf("GET /_sitemap/files/%s: %d %v\n%s", file, rec.Code, rec.Header(), rec.Body.String()) ++ } ++ paths := sitemapPaths(rec.Body.String()) ++ listed[file] = paths ++ slices.Sort(paths) ++ slices.Sort(want) ++ if !slices.Equal(paths, want) { ++ t.Errorf("%s lists %v, want %v", file, paths, want) ++ } ++ for _, p := range paths { ++ if strings.HasPrefix(p, "/blog/category/") || strings.HasPrefix(p, "/blog/post/") { ++ t.Errorf("%s carries a Journal default pattern: %s", file, p) ++ } ++ } ++ } ++ t.Logf("sitemap files: %v", listed) ++ for _, gone := range []string{"/sitemap.xsl", "/_sitemap/files/sitemap-tags-1.xml"} { ++ if rec := get(gone); rec.Code != http.StatusNotFound { ++ t.Errorf("GET %s: %d, want 404", gone, rec.Code) ++ } + } + + // CORS: the host's cors.paths cover the two public APIs, preflight and +diff --git a/config/golem15.journal.yaml b/config/golem15.journal.yaml +new file mode 100644 +index 0000000..2c2ec54 +--- /dev/null ++++ b/config/golem15.journal.yaml +@@ -0,0 +1,8 @@ ++# The Journal plugin (plugins/golem15/journal) on this site. ++# ++# The PHP site has no tag pages (the Nuxt app sets journal.routes.tag ++# false), so tags carry no URL: no journal-tag address resolves, none is ++# listed in the sitemaps and the SEO plugin answers not_found for ++# /blog/tag/*. Author pages stay on (golem15.journal.author_pages defaults ++# to true) at /autor/:author (config/menu_items.yaml). ++tag_pages: false +diff --git a/config/golem15.seo.yaml b/config/golem15.seo.yaml +index 2964ce2..58cebd2 100644 +--- a/config/golem15.seo.yaml ++++ b/config/golem15.seo.yaml +@@ -1,11 +1,21 @@ + # The SEO plugin (plugins/golem15/seo). site_url is empty, so absolute URLs +-# start with app.url (SUMMER_URL). Polish addresses carry no locale prefix. ++# start with app.url (SUMMER_URL) unless SUMMER_GOLEM15__SEO__SITE_URL sets ++# it. Polish addresses carry no locale prefix. + # notfound_token turns on the 404 report from the Nuxt server: set + # SUMMER_GOLEM15__SEO__NOTFOUND_TOKEN in go/.env and the same value as +-# NUXT_SEO_NOTFOUND_TOKEN in nuxt/.env. Outside SUMMER_ENV=production every ++# NUXT_SEO_NOTFOUND_TOKEN in nuxt/.env. route_report_token is the bearer of ++# the Nuxt server's route report (its static pages such as /kontakt and ++# /trwaja-prace become SEO › Pages entries); empty uses notfound_token, so ++# the 404 token alone turns both on. Outside SUMMER_ENV=production every + # resolve answer is noindex and /robots.txt disallows everything. ++# ++# robots.txt, the sitemaps and llms.txt reach visitors through the Nuxt ++# server (vue-seo-layer's root files), which reads them from Go on ++# loopback. robots_sitemaps names the sitemap index; the index lists the ++# per-type files. + site_url: "" + unprefixed_locale: pl + notfound_token: "" ++route_report_token: "" + robots_sitemaps: + - /sitemap.xml +diff --git a/config/golem15.sitemap.yaml b/config/golem15.sitemap.yaml +new file mode 100644 +index 0000000..ab039cb +--- /dev/null ++++ b/config/golem15.sitemap.yaml +@@ -0,0 +1,12 @@ ++# The sitemap plugin (plugins/golem15/sitemap): /sitemap.xml is an index of ++# per-type files (sitemap-posts-1.xml, sitemap-categories-1.xml, ++# sitemap-authors-1.xml, sitemap-pages-1.xml), which the Nuxt server's SEO ++# layer serves at the site root from Go's /_sitemap/files/ on loopback. ++# ++# site_url is the origin every starts with when the Site address ++# setting (Settings › Sitemaps) is empty: set ++# SUMMER_GOLEM15__SITEMAP__SITE_URL in go/.env to the public origin, the ++# same value as SUMMER_GOLEM15__SEO__SITE_URL. Without both, app.url is ++# used; without any of them /sitemap.xml answers 404 (a is never ++# built from the request's Host). ++site_url: "" +diff --git a/config/menu_items.yaml b/config/menu_items.yaml +index 15e5bc2..a604ea0 100644 +--- a/config/menu_items.yaml ++++ b/config/menu_items.yaml +@@ -1,12 +1,15 @@ + # Menu item URL patterns of this site (signpost, menu_items.patterns). + # + # The site serves Journal posts at /blog/:slug and categories at /:slug +-# (the Phase 16 URLs), not the Journal defaults /blog/post/:slug and +-# /blog/category/:slug. Only the two record types are overridden: the list ++# (the Phase 16 URLs) and authors at /autor/:author (the PHP author page), ++# not the Journal defaults /blog/post/:slug, /blog/category/:slug and ++# /blog/author/:author. Only the three record types are overridden: the list + # types all-journal-categories, all-journal-posts and category-journal-posts +-# take their children's URLs from these two through PatternFrom, so they +-# need no entry of their own. The menu, the sitemap and any other registry +-# consumer build the same URLs. ++# take their children's URLs from these through PatternFrom, so they need ++# no entry of their own. The menu, the sitemaps, the SEO plugin and any ++# other registry consumer build the same URLs. Tags have no pages here ++# (config/golem15.journal.yaml tag_pages false). + patterns: + journal-post: /blog/:slug + journal-category: /:slug ++ journal-author: /autor/:author +diff --git a/deploy/env.example b/deploy/env.example +index cefd2e4..61eee35 100644 +--- a/deploy/env.example ++++ b/deploy/env.example +@@ -38,12 +38,22 @@ SUMMER_SITE_CACHE__PURGE_TOKEN= + # with SQL. config/search.yaml holds the rest (127.0.0.1:8128). + SUMMER_SEARCH__TYPESENSE__API_KEY= + # The SEO plugin's public origin: canonical and social URLs and robots.txt's +-# Sitemap: line start with it. The same value as NUXT_SITE_URL in nuxt/.env. ++# Sitemap: line start with it. + SUMMER_GOLEM15__SEO__SITE_URL=https://grzybyfunkcjonalne.pl ++# The sitemap plugin's public origin: every of /sitemap.xml and its ++# files starts with it (the request's Host is never used). The same value as ++# SUMMER_GOLEM15__SEO__SITE_URL. Unset, app.url is used. ++SUMMER_GOLEM15__SITEMAP__SITE_URL=https://grzybyfunkcjonalne.pl + # The SEO plugin's 404 log: the Nuxt server reports each 404 with this bearer + # token (NUXT_SEO_NOTFOUND_TOKEN in nuxt/.env, the same value). Generated on +-# the server, never printed. Empty turns the report off. ++# the server, never printed. Empty turns the report off. The same token ++# carries the Nuxt server's route report to SEO › Pages unless ++# SUMMER_GOLEM15__SEO__ROUTE_REPORT_TOKEN sets its own. + SUMMER_GOLEM15__SEO__NOTFOUND_TOKEN= ++# Optional: a separate bearer token for the route report ++# (NUXT_SEO_ROUTE_REPORT_TOKEN in nuxt/.env, the same value). Leave it out ++# to use the 404 token. ++# SUMMER_GOLEM15__SEO__ROUTE_REPORT_TOKEN= + # + # Production (grzybyfunkcjonalne.pl, DEPLOY.md "Production cutover"): the + # same file with these values; every other key stays as on UAT. +@@ -51,3 +61,4 @@ SUMMER_GOLEM15__SEO__NOTFOUND_TOKEN= + # SUMMER_URL=https://grzybyfunkcjonalne.pl + # SUMMER_APP__URL=https://grzybyfunkcjonalne.pl + # SUMMER_GOLEM15__SEO__SITE_URL=https://grzybyfunkcjonalne.pl ++# SUMMER_GOLEM15__SITEMAP__SITE_URL=https://grzybyfunkcjonalne.pl +diff --git a/deploy/nginx/grzybyfunkcjonalne-production.conf b/deploy/nginx/grzybyfunkcjonalne-production.conf +index e469a8a..0da3320 100644 +--- a/deploy/nginx/grzybyfunkcjonalne-production.conf ++++ b/deploy/nginx/grzybyfunkcjonalne-production.conf +@@ -9,12 +9,14 @@ + # Two upstreams (D-07), both on loopback: + # - Go (supervisor program grzybyfunkcjonalne-go) on 127.0.0.1:8096: the + # admin at /gfadmin (D-09), the Journal API at /_journal/, the menu and +-# FAQ APIs at /_menu/ and /_faq/, /sitemap.xml and /sitemap.xsl from +-# the sitemap plugin (Phase 17 D-15), and /robots.txt and the public SEO +-# resolve endpoint from the SEO plugin, and the MCP endpoint at /mcp +-# with its protected resource metadata; ++# FAQ APIs at /_menu/ and /_faq/, the public SEO resolve endpoint from ++# the SEO plugin, and the MCP endpoint at /mcp with its protected ++# resource metadata; + # - Nuxt SSR (supervisor program grzybyfunkcjonalne-nuxt) on 127.0.0.1:3016: +-# every page and /rss.xml. ++# every page, /rss.xml and the root files: robots.txt, the sitemap index ++# /sitemap.xml and its /sitemap--.xml files, llms.txt and the ++# verification files, which the SEO layer (vue-seo-layer) reads from Go ++# on loopback. + # Uploaded and media images are served from the Go host's storage directory + # by nginx itself; nothing else under /storage/ is served (Pitfall 7). + +@@ -145,52 +147,14 @@ server { + proxy_set_header X-Forwarded-Proto $scheme; + } + +- # Go: the sitemap plugin (Phase 17 D-15). Exactly these two paths; the +- # sitemap builds its absolute URLs from Host and X-Forwarded-Proto, which +- # Go trusts from 127.0.0.1 only (http.trusted_proxies). +- location = /sitemap.xml { +- limit_except GET HEAD { +- deny all; +- } +- +- proxy_pass http://127.0.0.1:8096; +- 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; +- } +- +- location = /sitemap.xsl { +- limit_except GET HEAD { +- deny all; +- } +- +- proxy_pass http://127.0.0.1:8096; +- 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; +- } +- +- # Go: the SEO plugin. /robots.txt (Disallow: / outside production), and +- # the public resolve endpoint the SEO layer calls from the browser on +- # client-side navigations (SSR calls Go on loopback). Only these two +- # paths: the management API under /_seo/api/v1 stays off the public host. +- location = /robots.txt { +- limit_except GET HEAD { +- deny all; +- } +- +- proxy_pass http://127.0.0.1:8096; +- 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; +- } +- ++ # Go: the SEO plugin's public resolve endpoint, which the SEO layer calls ++ # from the browser on client-side navigations (SSR calls Go on loopback). ++ # Only this path: the management API under /_seo/api/v1 stays off the ++ # public host. robots.txt, the sitemaps (/sitemap.xml and its ++ # /sitemap--.xml files), llms.txt and the verification files are ++ # Nuxt's (location / below): the SEO layer reads them from Go on loopback ++ # (/robots.txt, /llms.txt, /_sitemap/files/), so /_sitemap/ has no ++ # location here either. + location = /_seo/api/v1/resolve { + limit_except GET HEAD { + deny all; +diff --git a/deploy/nginx/grzybyfunkcjonalne-staging.conf b/deploy/nginx/grzybyfunkcjonalne-staging.conf +index 2864257..4b113ff 100644 +--- a/deploy/nginx/grzybyfunkcjonalne-staging.conf ++++ b/deploy/nginx/grzybyfunkcjonalne-staging.conf +@@ -6,12 +6,14 @@ + # Two upstreams (D-07), both on loopback: + # - Go (supervisor program grzybyfunkcjonalne-go) on 127.0.0.1:8096: the + # admin at /gfadmin (D-09), the Journal API at /_journal/, the menu and +-# FAQ APIs at /_menu/ and /_faq/, /sitemap.xml and /sitemap.xsl from +-# the sitemap plugin (Phase 17 D-15), and /robots.txt and the public SEO +-# resolve endpoint from the SEO plugin, and the MCP endpoint at /mcp +-# with its protected resource metadata; ++# FAQ APIs at /_menu/ and /_faq/, the public SEO resolve endpoint from ++# the SEO plugin, and the MCP endpoint at /mcp with its protected ++# resource metadata; + # - Nuxt SSR (supervisor program grzybyfunkcjonalne-nuxt) on 127.0.0.1:3016: +-# every page and /rss.xml. ++# every page, /rss.xml and the root files: robots.txt, the sitemap index ++# /sitemap.xml and its /sitemap--.xml files, llms.txt and the ++# verification files, which the SEO layer (vue-seo-layer) reads from Go ++# on loopback. + # Uploaded and media images are served from the Go host's storage directory + # by nginx itself; nothing else under /storage/ is served (Pitfall 7). + +@@ -144,52 +146,14 @@ server { + proxy_set_header X-Forwarded-Proto $scheme; + } + +- # Go: the sitemap plugin (Phase 17 D-15). Exactly these two paths; the +- # sitemap builds its absolute URLs from Host and X-Forwarded-Proto, which +- # Go trusts from 127.0.0.1 only (http.trusted_proxies). +- location = /sitemap.xml { +- limit_except GET HEAD { +- deny all; +- } +- +- proxy_pass http://127.0.0.1:8096; +- 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; +- } +- +- location = /sitemap.xsl { +- limit_except GET HEAD { +- deny all; +- } +- +- proxy_pass http://127.0.0.1:8096; +- 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; +- } +- +- # Go: the SEO plugin. /robots.txt (Disallow: / outside production), and +- # the public resolve endpoint the SEO layer calls from the browser on +- # client-side navigations (SSR calls Go on loopback). Only these two +- # paths: the management API under /_seo/api/v1 stays off the public host. +- location = /robots.txt { +- limit_except GET HEAD { +- deny all; +- } +- +- proxy_pass http://127.0.0.1:8096; +- 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; +- } +- ++ # Go: the SEO plugin's public resolve endpoint, which the SEO layer calls ++ # from the browser on client-side navigations (SSR calls Go on loopback). ++ # Only this path: the management API under /_seo/api/v1 stays off the ++ # public host. robots.txt, the sitemaps (/sitemap.xml and its ++ # /sitemap--.xml files), llms.txt and the verification files are ++ # Nuxt's (location / below): the SEO layer reads them from Go on loopback ++ # (/robots.txt, /llms.txt, /_sitemap/files/), so /_sitemap/ has no ++ # location here either. + location = /_seo/api/v1/resolve { + limit_except GET HEAD { + deny all; +diff --git a/deploy/nuxt.env.example b/deploy/nuxt.env.example +index 6560614..67ea7aa 100644 +--- a/deploy/nuxt.env.example ++++ b/deploy/nuxt.env.example +@@ -1,21 +1,25 @@ + # Template for /srv/grzybyfunkcjonalne/nuxt/.env on the PHP site's server: + # owner grzybyfunkcjonalne (the deploy and service user), mode 0600. Read by node --env-file in +-# deploy/supervisor/grzybyfunkcjonalne-nuxt.conf. It holds two secrets, the +-# site cache purge token and the SEO 404 token; never commit or rsync the +-# real file. ++# deploy/supervisor/grzybyfunkcjonalne-nuxt.conf. It holds two secrets (three ++# with the optional route report token), the site cache purge token and the ++# SEO 404 token; never commit or rsync the real file. + # + # Listen on loopback only; nginx proxies to it. + NITRO_HOST=127.0.0.1 + NITRO_PORT=3016 + # Internal address of the Go Journal API for server-side rendering. + NUXT_JOURNAL_API_BASE=http://127.0.0.1:8096/_journal/api/v1 +-# Public origin for canonical URLs (the same value as +-# SUMMER_GOLEM15__SEO__SITE_URL in go/.env). +-NUXT_SITE_URL=https://uat.grzybyfunkcjonalne.pl +-# Staging is never indexed: noindex on every page (the SEO plugin adds the +-# same while go/.env has SUMMER_ENV=staging, and serves robots.txt). +-# Production removes this line. +-NUXT_SITE_INDEXABLE=false ++# The SEO layer (vue-seo-layer) on the SEO plugin: the API for SSR, the 404 ++# and route reports and the site answer, and the origin it reads ++# robots.txt, llms.txt and the sitemap files from (/_sitemap/files/). Both ++# on loopback; these are the defaults, written out so a port change is one ++# place. Canonical, Open Graph and hreflang URLs come from the plugin ++# (SUMMER_GOLEM15__SEO__SITE_URL in go/.env), and staging noindex from its ++# staging lock (SUMMER_ENV=staging in go/.env). Since vue-journal-layer 0.8 ++# the Nuxt app has no @nuxtjs/seo: NUXT_SITE_URL and NUXT_SITE_INDEXABLE ++# are no longer read and can be removed from an older file. ++NUXT_SEO_API_BASE=http://127.0.0.1:8096/_seo/api/v1 ++NUXT_SEO_BACKEND_BASE=http://127.0.0.1:8096 + # Google Analytics (D-23): empty on staging; production sets G-MLSV7WJ6W4. + NUXT_PUBLIC_GA_ID= + # Site cache purge token: the same value as +@@ -27,12 +31,13 @@ NUXT_PUBLIC_GA_ID= + NUXT_CACHE_PURGE_TOKEN= + # The SEO plugin's 404 log: the same value as SUMMER_GOLEM15__SEO__NOTFOUND_TOKEN + # in go/.env, generated on the server and never printed. Empty: 404s are +-# not reported. The SEO layer calls the plugin on http://127.0.0.1:8096 +-# (NUXT_SEO_API_BASE, the default). ++# not reported and the static pages are not reported to SEO › Pages. + NUXT_SEO_NOTFOUND_TOKEN= ++# Optional: the route report's own token, the same value as ++# SUMMER_GOLEM15__SEO__ROUTE_REPORT_TOKEN in go/.env. Unset, the 404 token ++# is used. ++# NUXT_SEO_ROUTE_REPORT_TOKEN= + # + # Production (grzybyfunkcjonalne.pl, DEPLOY.md "Production cutover"): the + # same file with these changes; every other key stays as on UAT. +-# NUXT_SITE_URL=https://grzybyfunkcjonalne.pl +-# NUXT_SITE_INDEXABLE removed + # NUXT_PUBLIC_GA_ID=G-MLSV7WJ6W4 +diff --git a/deploy/seo-pages.csv b/deploy/seo-pages.csv +new file mode 100644 +index 0000000..6325cea +--- /dev/null ++++ b/deploy/seo-pages.csv +@@ -0,0 +1,5 @@ ++url,locale,title,description,og_title ++/,pl,Witamy | Grzyby Funkcjonalne,Odkryj Sekrety Natury dla Lepszego Zdrowia, ++/blog,pl,,Serwis informacyjny poświęcony grzybom funkcjonalnym,Artykuły ++/kontakt,pl,,Skontaktuj Się z Ekspertami od Grzybów Funkcjonalnych,Kontakt ++/trwaja-prace,pl,,Serwis informacyjny poświęcony grzybom funkcjonalnym,Trwają prace +diff --git a/deploy/seo-settings.yaml b/deploy/seo-settings.yaml +index a8d406c..f8f4dc0 100644 +--- a/deploy/seo-settings.yaml ++++ b/deploy/seo-settings.yaml +@@ -18,6 +18,17 @@ settings: + # The post search: noindex,follow when a URL rule ever gives it an ok + # answer (the page sets noindex itself). + search_paths: /szukaj ++ # Page 2 and later of a list: "Artykuły · Strona 2 | Grzyby Funkcjonalne", ++ # the PHP site's list title (the description suffix keeps the plugin's ++ # default, "Strona N z M."). ++ page_title_suffix: "· Strona %page%" ++# Content type templates (Settings > SEO settings, the content type tabs). ++# The author pages kept the PHP title "Wpisy autora " and the site ++# description; the SEO plugin answers for them since it knows authors. ++types: ++ journal-author: ++ title_template: "Wpisy autora %title% | %site%" ++ description_template: Serwis informacyjny poświęcony grzybom funkcjonalnym + # The paths the Nuxt robots.txt disallowed before the SEO plugin served it. + # Sitemap: lines come from golem15.seo.robots_sitemaps. Outside + # SUMMER_ENV=production the plugin serves Disallow: / whatever this says. +diff --git a/plugins/grzyby/console/seo_settings.go b/plugins/grzyby/console/seo_settings.go +index b400b96..aca5f75 100644 +--- a/plugins/grzyby/console/seo_settings.go ++++ b/plugins/grzyby/console/seo_settings.go +@@ -16,12 +16,21 @@ import ( + ) + + // SEOSettingsFile is deploy/seo-settings.yaml: the SEO plugin's settings +-// columns (classes.SettingsColumns, string values) and the robots.txt text. +-// The PHP site kept these in its Golem15.SEO settings and its theme; the +-// file is the reviewed copy the command writes. ++// columns (classes.SettingsColumns, string values), the per-content-type ++// title and description templates (the settings' content type tabs) and ++// the robots.txt text. The PHP site kept these in its Golem15.SEO settings ++// and its theme; the file is the reviewed copy the command writes. + type SEOSettingsFile struct { +- Settings map[string]string `yaml:"settings"` +- Robots *string `yaml:"robots"` ++ Settings map[string]string `yaml:"settings"` ++ Types map[string]SEOTypeSettings `yaml:"types"` ++ Robots *string `yaml:"robots"` ++} ++ ++// SEOTypeSettings are the templates of one content type (its key, such as ++// journal-author); a template left out keeps its stored value. ++type SEOTypeSettings struct { ++ TitleTemplate *string `yaml:"title_template"` ++ DescriptionTemplate *string `yaml:"description_template"` + } + + // ReadSEOSettings reads and checks the file: every settings key must be a +@@ -40,6 +49,11 @@ func ReadSEOSettings(path string) (SEOSettingsFile, error) { + return SEOSettingsFile{}, fmt.Errorf("%s: settings.%s is not an SEO settings column", path, key) + } + } ++ for key, t := range f.Types { ++ if strings.TrimSpace(key) == "" || t.TitleTemplate == nil && t.DescriptionTemplate == nil { ++ return SEOSettingsFile{}, fmt.Errorf("%s: types.%s names no template", path, key) ++ } ++ } + return f, nil + } + +@@ -48,10 +62,12 @@ type SEOSettingsChange struct { + Field, Before, After string + } + +-// ApplySEOSettings writes the file's settings and robots.txt through the SEO +-// plugin's service in one transaction (validated like the admin screens and +-// recorded in its history with via import), or only reports the changes +-// when dryRun is set. Values already equal are left alone. ++// ApplySEOSettings writes the file's settings, its content type templates ++// and robots.txt through the SEO plugin's service in one transaction ++// (validated like the admin screens and recorded in its history with via ++// import), or only reports the changes when dryRun is set. Values already ++// equal are left alone. A content type the site does not register is ++// refused by the service. + func ApplySEOSettings(ctx context.Context, db *gorm.DB, svc *seoclasses.Service, f SEOSettingsFile, dryRun bool) ([]SEOSettingsChange, error) { + var changes []SEOSettingsChange + err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { +@@ -76,6 +92,41 @@ func ApplySEOSettings(ctx context.Context, db *gorm.DB, svc *seoclasses.Service, + changes = append(changes, SEOSettingsChange{Field: "settings." + k, Before: have, After: want}) + values[k] = want + } ++ types := map[string]seoclasses.TypeSettingWrite{} ++ typeKeys := make([]string, 0, len(f.Types)) ++ for k := range f.Types { ++ typeKeys = append(typeKeys, k) ++ } ++ slices.Sort(typeKeys) ++ for _, k := range typeKeys { ++ have, err := seoclasses.LoadTypeSetting(tx, k) ++ if err != nil { ++ return err ++ } ++ var w seoclasses.TypeSettingWrite ++ for _, field := range []struct { ++ name string ++ want *string ++ have string ++ set **string ++ }{ ++ {"title_template", f.Types[k].TitleTemplate, have.TitleTemplate, &w.TitleTemplate}, ++ {"description_template", f.Types[k].DescriptionTemplate, have.DescriptionTemplate, &w.DescriptionTemplate}, ++ } { ++ if field.want == nil { ++ continue ++ } ++ want := strings.TrimSpace(*field.want) ++ if want == field.have { ++ continue ++ } ++ changes = append(changes, SEOSettingsChange{Field: "types." + k + "." + field.name, Before: field.have, After: want}) ++ *field.set = &want ++ } ++ if w.TitleTemplate != nil || w.DescriptionTemplate != nil { ++ types[k] = w ++ } ++ } + var robots *string + if f.Robots != nil { + r, err := seoclasses.RobotsRow(tx) +@@ -90,8 +141,12 @@ func ApplySEOSettings(ctx context.Context, db *gorm.DB, svc *seoclasses.Service, + if dryRun { + return nil + } +- if len(values) > 0 { +- if _, err := svc.SaveSettings(ctx, tx, seoclasses.SettingsWrite{Values: values}); err != nil { ++ if len(values) > 0 || len(types) > 0 { ++ write := seoclasses.SettingsWrite{Values: values} ++ if len(types) > 0 { ++ write.Types = types ++ } ++ if _, err := svc.SaveSettings(ctx, tx, write); err != nil { + return err + } + } +@@ -109,7 +164,7 @@ func ApplySEOSettings(ctx context.Context, db *gorm.DB, svc *seoclasses.Service, + func SEOSettingsCommand(app *backpack.App) bonfire.Command { + return bonfire.Command{ + Name: "grzyby:seo-settings", +- Description: "Write the SEO plugin's settings and robots.txt from deploy/seo-settings.yaml.", ++ Description: "Write the SEO plugin's settings, content type templates and robots.txt from deploy/seo-settings.yaml.", + Flags: []bonfire.Flag{ + {Name: "file", Description: "The settings file (default deploy/seo-settings.yaml)"}, + {Name: "dry-run", Description: "Show what would change without writing anything", Bare: true}, +diff --git a/plugins/grzyby/console/seo_settings_test.go b/plugins/grzyby/console/seo_settings_test.go +index e8b3b40..5f95ca1 100644 +--- a/plugins/grzyby/console/seo_settings_test.go ++++ b/plugins/grzyby/console/seo_settings_test.go +@@ -16,8 +16,9 @@ import ( + + // TestSEOSettingsFile writes the committed deploy/seo-settings.yaml through + // the SEO plugin's service: a dry run changes nothing, the write makes the +-// home page resolve to the PHP site's title and description, robots.txt +-// carries the file's rules, and a second run finds nothing to change. ++// home page resolve to the PHP site's title and description, stores the ++// author pages' templates and the page title suffix, robots.txt carries ++// the file's rules, and a second run finds nothing to change. + func TestSEOSettingsFile(t *testing.T) { + db := requireDB(t) + f, err := ReadSEOSettings(filepath.Join("..", "..", "..", "deploy", "seo-settings.yaml")) +@@ -39,7 +40,7 @@ func TestSEOSettingsFile(t *testing.T) { + if err := lagoon.Publish(app, sqlDB, db); err != nil { + t.Fatal(err) + } +- if _, err := party.Activate(app, []string{"golem15.translate", "golem15.journal", "golem15.seo"}); err != nil { ++ if _, err := party.Activate(app, []string{"golem15.translate", "golem15.journal", "golem15.sitemap", "golem15.seo"}); err != nil { + t.Fatal(err) + } + svc, err := seoclasses.NewService(app) +@@ -85,6 +86,16 @@ func TestSEOSettingsFile(t *testing.T) { + t.Fatalf("robots.txt lacks %q:\n%s", want, body) + } + } ++ author, err := seoclasses.LoadTypeSetting(db, "journal-author") ++ if err != nil { ++ t.Fatal(err) ++ } ++ if author.TitleTemplate != "Wpisy autora %title% | %site%" || author.DescriptionTemplate != "Serwis informacyjny poświęcony grzybom funkcjonalnym" { ++ t.Fatalf("journal-author templates = %q / %q", author.TitleTemplate, author.DescriptionTemplate) ++ } ++ if st, err := seoclasses.LoadSettings(db); err != nil || st.PageTitleSuffix != "· Strona %page%" { ++ t.Fatalf("page_title_suffix = %q (%v)", st.PageTitleSuffix, err) ++ } + var n int64 + if err := db.Table("golem15_seo_revisions").Where("via = ?", "import").Count(&n).Error; err != nil { + t.Fatal(err) +@@ -110,6 +121,12 @@ func TestReadSEOSettingsRefusesUnknownKeys(t *testing.T) { + if _, err := ReadSEOSettings(path); err == nil || !strings.Contains(err.Error(), "site_title") { + t.Fatalf("err = %v, want the unknown key named", err) + } ++ if err := os.WriteFile(path, []byte("types:\n journal-author: {}\n"), 0o644); err != nil { ++ t.Fatal(err) ++ } ++ if _, err := ReadSEOSettings(path); err == nil || !strings.Contains(err.Error(), "journal-author") { ++ t.Fatalf("err = %v, want the empty type named", err) ++ } + if err := os.WriteFile(path, []byte("setings: {}\n"), 0o644); err != nil { + t.Fatal(err) + } +diff --git a/plugins/grzyby/console/testmain_test.go b/plugins/grzyby/console/testmain_test.go +index cf46192..7d3f6e8 100644 +--- a/plugins/grzyby/console/testmain_test.go ++++ b/plugins/grzyby/console/testmain_test.go +@@ -15,6 +15,7 @@ import ( + + _ "git.golem15.com/golem15/sm-journal-plugin" + _ "git.golem15.com/golem15/sm-seo-plugin" ++ _ "git.golem15.com/golem15/sm-sitemap-plugin" + _ "git.golem15.com/golem15/sm-translate-plugin" + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/compass" +@@ -173,7 +174,8 @@ func migrateTestDatabase(gdb *gorm.DB) error { + if err := lagoon.Publish(app, sqlDB, gdb); err != nil { + return err + } +- plugins, err := party.Activate(app, []string{"golem15.translate", "golem15.journal", "golem15.seo"}) ++ // golem15.seo requires golem15.sitemap (its sitemap filter and site answer). ++ plugins, err := party.Activate(app, []string{"golem15.translate", "golem15.journal", "golem15.sitemap", "golem15.seo"}) + if err != nil { + return err + } +diff --git a/plugins/grzyby/go.mod b/plugins/grzyby/go.mod +index 966fa1c..d5b78c9 100644 +--- a/plugins/grzyby/go.mod ++++ b/plugins/grzyby/go.mod +@@ -5,6 +5,7 @@ go 1.27.0 + require ( + git.golem15.com/golem15/sm-journal-plugin v0.0.0-00010101000000-000000000000 + git.golem15.com/golem15/sm-seo-plugin v0.0.0-00010101000000-000000000000 ++ git.golem15.com/golem15/sm-sitemap-plugin v0.0.0-00010101000000-000000000000 + git.golem15.com/golem15/sm-translate-plugin v0.1.0 + git.golem15.com/golem15/summercms v0.6.0 + github.com/goccy/go-yaml v1.19.2 +@@ -184,6 +185,8 @@ replace git.golem15.com/golem15/sm-journal-plugin => ../golem15/journal + + replace git.golem15.com/golem15/sm-seo-plugin => ../golem15/seo + ++replace git.golem15.com/golem15/sm-sitemap-plugin => ../golem15/sitemap ++ + replace git.golem15.com/golem15/sm-translate-plugin => ../golem15/translate + + replace git.golem15.com/golem15/sm-user-plugin => ../golem15/user +diff --git a/scripts/check-deploy.sh b/scripts/check-deploy.sh +index 4cb673c..683c2cd 100755 +--- a/scripts/check-deploy.sh ++++ b/scripts/check-deploy.sh +@@ -13,10 +13,13 @@ + # www to the apex; + # - each vhost keeps its security-relevant locations (D-09, T-16-11, T-16-16) + # and its server-level security headers, which no location overrides; +-# - the vhost routes /sitemap.xml, /sitemap.xsl, /_menu/ and /_faq/ to Go, +-# read-only (Phase 17 D-15), and /robots.txt and exactly +-# /_seo/api/v1/resolve of the SEO plugin, never the rest of /_seo/, and +-# never proxies to Typesense; ++# - the vhost routes /_menu/ and /_faq/ to Go, read-only (Phase 17 D-15), ++# and exactly /_seo/api/v1/resolve of the SEO plugin, never the rest of ++# /_seo/, and never proxies to Typesense; ++# - the root files (robots.txt, /sitemap.xml and its sitemap-*.xml files, ++# llms.txt) have no location of their own and /_sitemap/ none at all: ++# they reach Nuxt through location /, whose SEO layer reads them from Go ++# on loopback (Phase 33 P33-49); + # - the vhost routes /mcp (GET, POST, DELETE, unbuffered) and the two + # protected resource metadata paths (GET, HEAD) to Go, and + # config/mcp.yaml turns the endpoint on for personal tokens with the +@@ -243,11 +246,8 @@ check_site() { + 'location ^~ /backend' \ + 'location ^~ /gfadmin' \ + 'location ^~ /_journal/' \ +- 'location = /sitemap.xml' \ +- 'location = /sitemap.xsl' \ + 'location ^~ /_menu/' \ + 'location ^~ /_faq/' \ +- 'location = /robots.txt' \ + 'location = /_seo/api/v1/resolve' \ + 'location = /mcp' \ + 'location = /.well-known/oauth-protected-resource' \ +@@ -277,14 +277,14 @@ for n, raw in enumerate(open(path), 1): + if location is not None and depth <= location: + location = None + EOF +- # The Phase 17 locations go to Go with the headers the sitemap builds its +- # URLs from, and pass GET and HEAD only. ++ # The Phase 17 and SEO locations go to Go with the forwarded headers, and ++ # pass GET and HEAD only. + python3 -I - "$SITE_CONF" <<'EOF' || fail "a Phase 17 Go location is incomplete" + import re + import sys + + text = open(sys.argv[1]).read() +-for loc in ("= /sitemap.xml", "= /sitemap.xsl", "^~ /_menu/", "^~ /_faq/", "= /robots.txt", "= /_seo/api/v1/resolve"): ++for loc in ("^~ /_menu/", "^~ /_faq/", "= /_seo/api/v1/resolve"): + m = re.search(r"location " + re.escape(loc) + r" \{(.*?)\n\t\}", text, re.S) + if not m: + sys.exit(f"location {loc} missing") +@@ -319,6 +319,10 @@ for loc, extra in want.items(): + EOF + # The SEO management API stays off the public host: no other /_seo location. + grep -E '^\s*location[^{]*/_seo' "$SITE_CONF" | grep -vqF 'location = /_seo/api/v1/resolve {' && fail "$SITE_CONF exposes more of /_seo/ than the resolve endpoint" ++ # The root files are Nuxt's (the SEO layer reads them from Go on ++ # loopback) and the sitemap plugin's /_sitemap/ stays off the public ++ # host: no location names robots.txt, llms.txt, a sitemap or /_sitemap. ++ grep -Eq '^\s*location[^{]*(robots\.txt|llms\.txt|sitemap)' "$SITE_CONF" && fail "$SITE_CONF routes a root file or /_sitemap/ past Nuxt; the SEO layer serves them (P33-49)" + # Typesense stays on loopback: no location proxies to it. + grep -Eiq '127\.0\.0\.1:812[78]|localhost:812[78]|typesense' "$SITE_CONF" && fail "$SITE_CONF must not reach Typesense" + # A ^~ /storage/ prefix would stop nginx before the image regex location. +diff --git a/scripts/migrate.sh b/scripts/migrate.sh +index 48531a7..e2c5eeb 100755 +--- a/scripts/migrate.sh ++++ b/scripts/migrate.sh +@@ -4,7 +4,8 @@ + # + # 1. migrate + # 2. refuse a database that already holds journal posts (Pitfall 3) +-# 3. make pl the only default locale and assert it (Pitfall 1) ++# 3. make pl the only default and the only enabled locale and assert it ++# (Pitfall 1; the PHP site is Polish only) + # 4. the post import from the PHP journal export (dry run, then real), + # run through grzyby:import-posts, which opens the database first + # 5. category names, descriptions and content (grzyby:import-categories), +@@ -14,7 +15,10 @@ + # 8. the post author and its /autor/ slug, when --author-login (D-15) + # 9. the FAQ, menu and sitemap rows of the PHP site + # (scripts/import-companions.sh, dry run, then real), when --companions +-# (Phase 17 D-19) ++# (Phase 17 D-19); the sitemap definition's url items (and --page-url ++# items) become the sitemap's Additional addresses, its content type ++# items are dropped (the content types list their own records), and ++# the files exist only after sitemap:rebuild (DEPLOY.md) + # 10. a gap report: what the migration knowingly leaves broken (D-17) + # + # scripts/migrate.sh --workdir DIR --export DIR [options] +@@ -187,13 +191,19 @@ posts="$(sql "SELECT count(*) FROM golem15_journal_posts")" || die "cannot count + + # 3. pl is the only default locale before the import (Pitfall 1): the host + # slug, title and content columns then hold the pl values the live URLs use. ++# It is also the only enabled locale: the site serves no other (the Nuxt ++# app is Polish only, /en/... answers 404), and an enabled locale without ++# content would still count as a version of every record, so the SEO ++# plugin's hreflang links and the sitemaps would name /en/... addresses. + if [[ -n "$DRY" ]]; then +- step "dry run: would set pl as the only default locale" ++ step "dry run: would set pl as the only default and enabled locale" + else +- step "make pl the default locale" +- sql "UPDATE golem15_translate_locales SET is_default = (code = 'pl')" >/dev/null ++ step "make pl the default and only enabled locale" ++ sql "UPDATE golem15_translate_locales SET is_default = (code = 'pl'), is_enabled = (code = 'pl')" >/dev/null + defaults="$(sql "SELECT code FROM golem15_translate_locales WHERE is_default AND is_enabled ORDER BY code")" + [[ "$defaults" == "pl" ]] || die "default locale is '${defaults//$'\n'/,}', want exactly pl" ++ enabled="$(sql "SELECT code FROM golem15_translate_locales WHERE is_enabled ORDER BY code")" ++ [[ "$enabled" == "pl" ]] || die "enabled locales are '${enabled//$'\n'/,}', want exactly pl" + fi + + # 4. Posts: journal:import-posts, once. grzyby:import-posts runs the Journal +@@ -262,8 +272,12 @@ SQL + fi + + # 9. The PHP site's FAQ, menu and sitemap rows (Phase 17 D-19), after the +-# Journal, category and author steps: the menu and sitemap types resolve +-# against the imported categories. A dry run first, then the real import. ++# Journal, category and author steps: the menu types resolve against the ++# imported categories. The sitemap's url items go to its Additional ++# addresses (the pages file); its post and category items are dropped, ++# since the per-type files list every published post, category and ++# author. A dry run first, then the real import. The sitemap files are ++# written by the next sitemap:rebuild (the worker queues one, or run it). + if [[ -n "$COMPANIONS" ]]; then + companions=(bash "$ROOT/scripts/import-companions.sh" --bin "$BIN" --workdir "$WORKDIR" --dir "$COMPANIONS" + ${COMPANIONS_ARGS[@]+"${COMPANIONS_ARGS[@]}"}) +diff --git a/scripts/smoke-seo.sh b/scripts/smoke-seo.sh +new file mode 100755 +index 0000000..867b1db +--- /dev/null ++++ b/scripts/smoke-seo.sh +@@ -0,0 +1,202 @@ ++#!/usr/bin/env bash ++# Check the SEO output of grzybyfunkcjonalne.pl through its public front ++# (the Nuxt server, or nginx in front of it): ++# ++# scripts/smoke-seo.sh BASE_URL ++# ++# - a post, a category, /kontakt and the home page render exactly one ++# , ++# each once and with a , no /en/ address or hreflang ++# alternate, and no or ; ++# - the pages file holds /, /blog, /kontakt and /trwaja-prace (the static ++# pages the route report registered; / and /blog were also the PHP ++# sitemap definition's url items) and no /szukaj; ++# - no tag file and no /sitemap.xsl (404); robots.txt is text/plain. ++# ++# It reads only public URLs, so it runs against the local rehearsal ++# (scripts/smoke.sh runs it last) and against the server (DEPLOY.md, ++# "Smoke": on the server, with curl resolving the host to loopback through ++# CURL_HOME). It ends with "smoke-seo: ok". ++set -euo pipefail ++ ++fail() { ++ echo "smoke-seo: FAIL $*" >&2 ++ exit 1 ++} ++ ++[[ $# -eq 1 && ( "$1" == http://* || "$1" == https://* ) ]] || { ++ echo "usage: scripts/smoke-seo.sh BASE_URL" >&2 ++ exit 2 ++} ++BASE="${1%/}" ++for tool in curl python3; do ++ command -v "$tool" >/dev/null 2>&1 || fail "$tool not found on PATH" ++done ++ ++TMP="$(mktemp -d)" ++trap 'rm -rf "$TMP"' EXIT ++ ++# fetch PATH writes the body to $TMP/body and prints "STATUSTYPE". ++fetch() { ++ curl -sS -o "$TMP/body" -w '%{http_code}\t%{content_type}' --max-time 30 \ ++ -H 'X-Forwarded-For: 203.0.113.250' "$BASE$1" 2>"$TMP/curl.err" || echo "000"$'\t'"$(head -c 200 "$TMP/curl.err")" ++} ++ ++# want PATH STATUS: the path answers STATUS, the body is in $TMP/body. ++want() { ++ local got ++ got="$(fetch "$1")" ++ [[ "${got%%$'\t'*}" == "$2" ]] || fail "$1: got ${got%%$'\t'*}, want $2" ++ CTYPE="${got#*$'\t'}" ++} ++ ++# page PATH [BREADCRUMB_HREF]: one H1, one canonical, one JSON-LD graph; ++# with BREADCRUMB_HREF, the breadcrumbs link it and the graph has a ++# BreadcrumbList. ++page() { ++ want "$1" 200 ++ python3 -I - "$TMP/body" "$1" "${2:-}" <<'EOF' || fail "$1: head checks failed" ++import json ++import re ++import sys ++ ++body = open(sys.argv[1], encoding="utf-8").read() ++path, crumb = sys.argv[2], sys.argv[3] ++# Markup outside ", "", body, flags=re.S | re.I) ++h1 = len(re.findall(r"]", markup, re.I)) ++canonical = len(re.findall(r"]+rel=\"canonical\"", body, re.I)) ++graphs = re.findall(r"]+type=\"application/ld\+json\"[^>]*>(.*?)", body, re.S | re.I) ++problems = [] ++if h1 != 1: ++ problems.append(f"{h1}

]+hreflang=\"en\"", body) or "grzybyfunkcjonalne.pl/en/" in body: ++ problems.append("links an /en/ version (the site is Polish only)") ++if crumb: ++ nav = re.search(r"]*class=\"[^\"]*seo-breadcrumbs[^\"]*\"[^>]*>(.*?)", markup, re.S | re.I) ++ if not nav: ++ problems.append("no nav.seo-breadcrumbs") ++ elif f'href="{crumb}"' not in nav.group(1): ++ problems.append(f"the breadcrumbs do not link {crumb}") ++if problems: ++ sys.exit(f"smoke-seo: FAIL {path}: " + "; ".join(problems)) ++EOF ++ echo "smoke-seo: $1 one H1, one canonical, one JSON-LD graph${2:+, breadcrumbs through $2}" ++} ++ ++page /blog/blogbadania-nad-cordycepsem /cordyceps ++page /cordyceps ++page /kontakt ++page / ++ ++# sitemap FILE NEEDLE...: FILE is an XML urlset through the front, with ++# absolute and and no priority or changefreq; every NEEDLE ++# (a path) is listed, a NEEDLE starting with ! is not. ++sitemap() { ++ local file="$1" ++ shift ++ want "/$file" 200 ++ [[ "${CTYPE,,}" == application/xml* ]] || fail "/$file: content type $CTYPE" ++ python3 -I - "$TMP/body" "$file" "$@" <<'EOF' || exit 1 ++import re ++import sys ++ ++body = open(sys.argv[1], encoding="utf-8").read() ++name, needles = sys.argv[2], sys.argv[3:] ++problems = [] ++if "([^<]*)", body) ++if not locs: ++ problems.append("no ") ++if any(not re.match(r"https?://[^/]+/", l + "/") for l in locs): ++ problems.append("a is not absolute") ++if body.count("") < len(locs): ++ problems.append("an address without ") ++if "" in body or "" in body: ++ problems.append("priority or changefreq written") ++if len(set(locs)) != len(locs): ++ dupes = sorted({l for l in locs if locs.count(l) > 1}) ++ problems.append(f"lists an address twice: {' '.join(dupes)}") ++if "/en/" in body or "hreflang=" in body: ++ problems.append("names an /en/ address or hreflang alternates (the site is Polish only)") ++paths = {re.sub(r"^https?://[^/]+", "", l) or "/" for l in locs} ++for n in needles: ++ if n.startswith("!"): ++ if any(p == n[1:] or p.startswith(n[1:] + "?") for p in paths): ++ problems.append(f"lists {n[1:]}") ++ elif n not in paths: ++ problems.append(f"does not list {n}") ++if problems: ++ sys.exit(f"smoke-seo: FAIL /{name}: " + "; ".join(problems)) ++print(f"smoke-seo: /{name} {len(locs)} addresses") ++EOF ++} ++ ++# The index. ++want /sitemap.xml 200 ++[[ "${CTYPE,,}" == application/xml* ]] || fail "/sitemap.xml: content type $CTYPE" ++python3 -I - "$TMP/body" <<'EOF' || exit 1 ++import re ++import sys ++ ++body = open(sys.argv[1], encoding="utf-8").read() ++problems = [] ++if "(.*?)", body, re.S) ++files = {} ++for e in entries: ++ loc = re.search(r"([^<]*)", e) ++ if loc: ++ files[loc.group(1).rsplit("/", 1)[-1]] = "" in e ++for want in ("sitemap-posts-1.xml", "sitemap-categories-1.xml", "sitemap-authors-1.xml", "sitemap-pages-1.xml"): ++ if want not in files: ++ problems.append(f"does not list {want}") ++ elif not files[want]: ++ problems.append(f"{want} has no lastmod") ++if any(f.startswith("sitemap-tags-") for f in files): ++ problems.append("lists a tag file") ++if problems: ++ sys.exit("smoke-seo: FAIL /sitemap.xml: " + "; ".join(problems)) ++print(f"smoke-seo: /sitemap.xml index of {len(files)} files: {' '.join(sorted(files))}") ++EOF ++ ++sitemap sitemap-posts-1.xml /blog/blogbadania-nad-cordycepsem '!/blog/badania-nad-cordycepsem' ++sitemap sitemap-categories-1.xml /cordyceps /amanita-muscaria ++sitemap sitemap-authors-1.xml /autor/admin ++sitemap sitemap-pages-1.xml / /blog /kontakt /trwaja-prace '!/szukaj' ++ ++want /sitemap-tags-1.xml 404 ++want /sitemap.xsl 404 ++want /robots.txt 200 ++[[ "${CTYPE,,}" == text/plain* ]] || fail "/robots.txt: content type $CTYPE" ++grep -q '^User-agent:' "$TMP/body" || fail "/robots.txt has no User-agent line" ++ ++echo "smoke-seo: ok" +diff --git a/scripts/smoke.sh b/scripts/smoke.sh +index 958e178..c20eede 100755 +--- a/scripts/smoke.sh ++++ b/scripts/smoke.sh +@@ -12,12 +12,19 @@ + # Full (default): needs the scripts/build.sh outputs (bin/grzybyfunkcjonalne + # and build/nuxt/.output). It migrates, creates the admin user (random + # password on stdin), runs scripts/migrate.sh with the testdata category and +-# featured-image maps and --author-login admin --author-slug admin, serves Go +-# and Nuxt, runs the Go checks and then checks every urls.txt line not +-# marked remote: /gfadmin, /_journal, /_menu, /_faq, /_seo, /sitemap.*, +-# /robots.txt and /backend against Go, everything else against Nuxt (with +-# the SEO layer on the rehearsal Go), each with its own +-# X-Forwarded-For visitor 203.0.113.N. ++# featured-image maps, --author-login admin --author-slug admin and the ++# testdata companions (FAQ, menu and the sitemap definition, whose url ++# items become the sitemap's Additional addresses), serves Go and Nuxt ++# (with the 404 and route report token), waits for Nuxt's route report to ++# register its static pages, names /blog and /trwaja-prace in SEO › Pages ++# as the PHP site did, imports deploy/seo-pages.csv through the SEO ++# plugin's CSV import, empties the Additional addresses the route report ++# superseded (/ and /blog), rebuilds the sitemaps, runs the Go checks and ++# then checks every urls.txt line not marked remote: /gfadmin, /_journal, ++# /_menu, /_faq, /_seo and /backend against Go, everything else (pages, ++# robots.txt, the sitemaps) against Nuxt, whose SEO layer serves the root ++# files from Go, each with its own X-Forwarded-For visitor 203.0.113.N. ++# Last it runs scripts/smoke-seo.sh against Nuxt. + # --go-only: the same without Nuxt, the admin user and urls.txt; it needs only + # bin/grzybyfunkcjonalne. Checks the Journal API, the categories, the admin + # mount and the featured-image URL. +@@ -34,6 +41,9 @@ + # (default /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/journal-export) + # SMOKE_GO_PORT the Go listen port (default 18096) + # SMOKE_NUXT_PORT the Nuxt listen port (default 13016) ++# SMOKE_KEEP=1 after a passing full rehearsal, keep Go and Nuxt ++# running until interrupted (Ctrl-C), to check them by ++# hand or with scripts/smoke-seo.sh + set -euo pipefail + + ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +@@ -75,7 +85,7 @@ while [[ $# -gt 0 ]]; do + URLS_ALSO="$2" + shift 2 + ;; +- -h | --help) sed -n '2,36p' "$0"; exit 0 ;; ++ -h | --help) sed -n '2,46p' "$0"; exit 0 ;; + *) fail "unknown argument: $1" ;; + esac + done +@@ -149,7 +159,7 @@ check_urls() { + continue + fi + case "$path" in +- /gfadmin* | /_journal* | /_menu* | /_faq* | /_seo* | /sitemap.* | /robots.txt | /backend*) base="$go_base" ;; ++ /gfadmin* | /_journal* | /_menu* | /_faq* | /_seo* | /backend*) base="$go_base" ;; + *) base="$nuxt_base" ;; + esac + got="$(curl -sS -o "$TMP/body" -w '%{http_code}\t%{content_type}' --max-time 30 \ +@@ -260,6 +270,8 @@ WORK="$TMP/work" + mkdir -p "$WORK" + cp -R "$ROOT/config" "$WORK/config" + secret() { head -c 32 /dev/urandom | base64 | tr -d '\n'; } ++# The 404 and route report token of the rehearsal, shared with Nuxt. ++NOTFOUND_TOKEN="$(head -c 32 /dev/urandom | base64 | tr '+/' '-_' | tr -d '=\n')" + cat >"$WORK/.env" <"$TMP/migrate.log" 2>&1 || { +@@ -422,7 +443,8 @@ fi + (exec env -i PATH="$PATH" HOME="$TMP" NODE_ENV=production \ + NITRO_HOST=127.0.0.1 NITRO_PORT="$NUXT_PORT" \ + NUXT_JOURNAL_API_BASE="$GO/_journal/api/v1" NUXT_MENU_API_BASE="$GO/_menu/api/v1" \ +- NUXT_FAQ_API_BASE="$GO/_faq/api/v1" NUXT_SEO_API_BASE="$GO/_seo/api/v1" NUXT_SITE_INDEXABLE=false \ ++ NUXT_FAQ_API_BASE="$GO/_faq/api/v1" NUXT_SEO_API_BASE="$GO/_seo/api/v1" NUXT_SEO_BACKEND_BASE="$GO" \ ++ NUXT_SEO_NOTFOUND_TOKEN="$NOTFOUND_TOKEN" \ + node "$NUXT_ENTRY") >"$TMP/nuxt.log" 2>&1 & + NUXT_PID=$! + up="" +@@ -439,5 +461,90 @@ if [[ -z "$up" ]]; then + fail "Nuxt did not start" + fi + ++# The route report: once Nuxt has posted its static routes, /kontakt is a ++# registry page and resolves ok. ++reported="" ++for _ in $(seq 1 100); do ++ if curl -sS "$GO/_seo/api/v1/resolve?url=%2Fkontakt" | grep -qF '"action":"ok"'; then ++ reported=1 ++ break ++ fi ++ sleep 0.2 ++done ++if [[ -z "$reported" ]]; then ++ cat "$TMP/nuxt.log" >&2 ++ fail "Nuxt's route report did not register /kontakt" ++fi ++ ++# The release's data steps, as DEPLOY.md runs them on the server, through ++# the SEO and sitemap management APIs with a personal token of the admin ++# (scope *: the SEO and sitemap plugins declare no token scope of their ++# own), printed once by the command, kept in this shell and revoked at the ++# end of the steps. ++TOKEN="$(run admin:token:create admin --name smoke-seo-import --scope '*' --expires 30d -q 2>"$TMP/token.err" | tail -n1)" || { ++ cat "$TMP/token.err" >&2 ++ fail "admin:token:create failed" ++} ++api() { ++ curl -sS -H @<(printf 'Authorization: Bearer %s\n' "$TOKEN") -H 'Content-Type: application/json' "$@" ++} ++ ++# 1. The route report names each page after its last path segment (no ++# diacritics). The name is the page's %title% in the title template, and ++# shows in the HTML sitemap, the breadcrumbs and the structured data, so ++# /blog and /trwaja-prace get the PHP pages' names. ++for entry in "/blog=Artykuły" "/trwaja-prace=Trwają prace"; do ++ path="${entry%%=*}" name="${entry#*=}" ++ id="$(api "$GO/_seo/api/v1/pages?search=${path#/}" | jq -r --arg p "$path" '.data[] | select(.url == $p) | .id')" ++ [[ "$id" =~ ^[0-9]+$ ]] || fail "no SEO › Pages entry for $path" ++ renamed="$(jq -n --arg n "$name" '{title: {pl: $n}}' | api -X PUT --data-binary @- "$GO/_seo/api/v1/pages/$id" | jq -r '.data.title // empty')" ++ [[ "$renamed" == "$name" ]] || fail "renaming $path: '$renamed'" ++done ++ ++# 2. deploy/seo-pages.csv through the SEO plugin's CSV import (records by ++# URL): a dry run must refuse nothing, then the import applies every row ++# as one batch. ++seo_import() { ++ jq -n --rawfile csv "$ROOT/deploy/seo-pages.csv" --argjson dry "$1" '{dataset: "records", csv: $csv, dry_run: $dry}' | ++ api -X POST --data-binary @- "$GO/_seo/api/v1/import" ++} ++preview="$(seo_import true)" ++[[ "$(jq -r '.data.summary.refused // .summary.refused // "x"' <<<"$preview")" == 0 ]] || fail "seo-pages.csv dry run: $preview" ++applied="$(seo_import false)" ++[[ "$(jq -r '.data.applied // .applied // false' <<<"$applied")" == true ]] || fail "seo-pages.csv import: $applied" ++ ++# 3. The PHP sitemap definition's url items are the sitemap's Additional ++# addresses now (/ and /blog). The route report registered the same two ++# paths as SEO › Pages entries, which share the pages file, so the ++# Additional addresses are emptied; otherwise the pages file would list ++# / and /blog twice. ++extras="$(api "$GO/_sitemap/api/v1/settings" | jq -c '[.data.settings.extra_urls[]?.url]')" ++[[ "$extras" == '["/","/blog"]' ]] || fail "Additional addresses after the companions import: $extras, want [\"/\",\"/blog\"]" ++cleared="$(jq -n '{extra_urls: []}' | api -X PUT --data-binary @- "$GO/_sitemap/api/v1/settings" | jq -c '[.data.settings.extra_urls[]?]')" ++[[ "$cleared" == '[]' ]] || fail "emptying the Additional addresses: $cleared" ++unset TOKEN ++run admin:token:list >"$TMP/tokens.log" 2>&1 || true ++token_id="$(awk '/smoke-seo-import/{print $1; exit}' "$TMP/tokens.log")" ++[[ -n "$token_id" ]] && run admin:token:revoke "$token_id" >/dev/null 2>&1 || true ++ ++# The sitemap files exist after a rebuild (the worker in serve queues one ++# after record events too; this one runs now, in the process). ++run sitemap:rebuild >"$TMP/rebuild.log" 2>&1 || { ++ cat "$TMP/rebuild.log" >&2 ++ fail "sitemap:rebuild failed" ++} ++grep -q 'Rebuilt the sitemaps: ' "$TMP/rebuild.log" || { ++ cat "$TMP/rebuild.log" >&2 ++ fail "sitemap:rebuild did not report a rebuild" ++} ++ + check_urls "$GO" "$NUXT" "" + echo "smoke: ok ($CHECKED urls)" ++ ++bash "$ROOT/scripts/smoke-seo.sh" "$NUXT" || fail "smoke-seo.sh failed" ++ ++if [[ "${SMOKE_KEEP:-}" == 1 ]]; then ++ echo "smoke: keeping Go on $GO and Nuxt on $NUXT; interrupt to stop" ++ trap 'exit 0' INT TERM ++ while kill -0 "$GO_PID" 2>/dev/null && kill -0 "$NUXT_PID" 2>/dev/null; do sleep 1; done ++fi +diff --git a/scripts/urls-production.txt b/scripts/urls-production.txt +index 2427f66..6ea4935 100644 +--- a/scripts/urls-production.txt ++++ b/scripts/urls-production.txt +@@ -6,14 +6,15 @@ + # robots.txt: the stored rules, not the staging lock. + remote 200 /robots.txt type=text/plain contains=Disallow: /gfadmin + remote 200 /robots.txt contains=Sitemap: https://grzybyfunkcjonalne.pl/sitemap.xml +-# A post is indexable with its canonical on the apex. The page payload +-# carries the robots module's "noindex, nofollow" disabled value as config, +-# so the check matches the meta tag, not the bare word. ++# A post is indexable with its canonical on the apex: the SEO layer writes ++# the plugin's robots value as the meta tag (the check matches the tag, not ++# the bare word, which the payload may carry). + remote 200 /blog/blogchaga-wprowadzenie contains= + remote 200 /blog/blogchaga-wprowadzenie contains= + remote 200 / contains= + # Google Analytics (D-23). + remote 200 / contains=https://www.googletagmanager.com/gtag/js?id=G-MLSV7WJ6W4 +-# The sitemap and the MCP resource name the apex. +-remote 200 /sitemap.xml contains=https://grzybyfunkcjonalne.pl/blog/blogchaga-wprowadzenie ++# The sitemap index, its files and the MCP resource name the apex. ++remote 200 /sitemap.xml contains=https://grzybyfunkcjonalne.pl/sitemap-posts-1.xml ++remote 200 /sitemap-posts-1.xml contains=https://grzybyfunkcjonalne.pl/blog/blogchaga-wprowadzenie + remote 200 /.well-known/oauth-protected-resource/mcp contains="resource":"https://grzybyfunkcjonalne.pl/mcp" +diff --git a/scripts/urls.txt b/scripts/urls.txt +index 488d381..e3021b9 100644 +--- a/scripts/urls.txt ++++ b/scripts/urls.txt +@@ -1,9 +1,10 @@ + # Every old URL of grzybyfunkcjonalne.pl and what the new stack answers (D-15). + # Grammar: [remote] STATUS PATH [contains=TEXT] [notcontains=TEXT] [type=PREFIX] +-# Lines marked remote need nginx (or the imported FAQ, menu and sitemap +-# rows) and are checked only with smoke.sh --url. The local rehearsal sends +-# /gfadmin, /_journal, /_menu, /_faq, /_seo, /sitemap.*, /robots.txt and +-# /backend to Go and every other path to Nuxt. ++# Lines marked remote need nginx (or the server's FAQ and menu rows) and ++# are checked only with smoke.sh --url. The local rehearsal sends /gfadmin, ++# /_journal, /_menu, /_faq, /_seo and /backend to Go and every other path ++# to Nuxt, robots.txt and the sitemaps included (the SEO layer serves them ++# from Go, as through nginx). + # + # The live sitemap, https://grzybyfunkcjonalne.pl/sitemap.xml, fetched + # 2026-10-09: 71 (/, /blog, 7 categories, 62 posts). +@@ -107,12 +108,14 @@ + 404 /szukaj?q=cordyceps&page=999 + 200 /blog contains=action="/szukaj" + 200 /_journal/api/v1/posts?search=cordyceps&fields=suggest&per_page=5 type=application/json contains="slug" +-remote 200 /sitemap.xml notcontains=/szukaj ++200 /sitemap-pages-1.xml notcontains=/szukaj + +-# The SEO plugin (golem15.seo) and the SEO layer: robots.txt from Go, the +-# head of every post and category from the plugin's answer (the X account and +-# the "%title% | %site%" template from deploy/seo-settings.yaml) and the +-# public resolve endpoint. ++# The SEO plugin (golem15.seo) and the SEO layer: robots.txt from Go ++# through the layer, the head of every page from the plugin's answer (the X ++# account and the "%title% | %site%" template from deploy/seo-settings.yaml, ++# the static pages' titles and descriptions from deploy/seo-pages.csv) and ++# the public resolve endpoint. The layer owns the head, so a page has one ++# title and one canonical. + 200 /robots.txt type=text/plain contains=Disallow: + 200 /_seo/api/v1/resolve?url=%2Fcordyceps type=application/json contains="action":"ok" + 200 /_seo/api/v1/resolve?url=%2Fnieistnieje type=application/json contains="action":"not_found" +@@ -121,6 +124,15 @@ remote 200 /sitemap.xml notcontains=/szukaj + 200 /cordyceps contains= ++200 / contains= ++200 /kontakt contains=Kontakt | Grzyby Funkcjonalne ++200 /kontakt contains= ++200 /trwaja-prace contains=Trwają prace | Grzyby Funkcjonalne ++200 /trwaja-prace contains= ++200 /blog contains=Artykuły | Grzyby Funkcjonalne ++200 /blog?page=2 contains=Artykuły · Strona 2 | Grzyby Funkcjonalne ++200 /autor/admin contains=Wpisy autora admin | Grzyby Funkcjonalne ++200 /szukaj?q=cordyceps contains=Wyniki wyszukiwania: cordyceps | Grzyby Funkcjonalne + + # Real 404s where the PHP site answered a soft 200. + 404 /404 +@@ -141,11 +153,29 @@ remote 200 /sitemap.xml notcontains=/szukaj + 404 /backend + 200 /_journal/api/v1/posts type=application/json + +-# Blog companions (Phase 17): the sitemap, the menu and the FAQ come from the +-# Go plugins with the PHP site's rows (scripts/import-companions.sh), routed +-# by nginx; the homepage renders the menu main-menu and the FAQ. +-remote 200 /sitemap.xml type=application/xml contains=https://grzybyfunkcjonalne.pl/sitemap-posts-1.xml ++200 /sitemap-posts-1.xml type=application/xml contains=https://grzybyfunkcjonalne.pl/blog/blogbadania-nad-cordycepsem ++200 /sitemap-categories-1.xml type=application/xml contains=https://grzybyfunkcjonalne.pl/cordyceps ++200 /sitemap-authors-1.xml type=application/xml contains=https://grzybyfunkcjonalne.pl/autor/admin ++200 /sitemap-pages-1.xml type=application/xml contains=https://grzybyfunkcjonalne.pl/blog ++200 /sitemap-pages-1.xml contains=https://grzybyfunkcjonalne.pl/kontakt ++404 /sitemap-tags-1.xml ++404 /sitemap.xsl ++# The HTML sitemap of the SEO layer (new in Phase 33; /mapa-strony was a 404 ++# through /:category before), linked from the footer, with the registry ++# page names (/trwaja-prace renamed in SEO › Pages, DEPLOY.md). ++200 /mapa-strony contains=Trwają prace notcontains=Trwaja prace ++200 / contains=href="/mapa-strony" ++ ++# Blog companions (Phase 17): the menu and the FAQ come from the Go plugins ++# with the PHP site's rows (scripts/import-companions.sh), routed by nginx; ++# the homepage renders the menu main-menu and the FAQ. + remote 200 /_menu/api/v1/menus/main-menu type=application/json contains="title":"Grzyby Funkcjonalne" + remote 200 /_faq/api/v1/faqs/what-mushrooms-can-give-you type=application/json contains="question":"Co to są grzyby funkcjonalne" + remote 200 / contains=Blog diff --git a/.planning/phases/33-seo-output-audit-and-content/33-13-vue-grzybyfunkcjonalne-app.patch b/.planning/phases/33-seo-output-audit-and-content/33-13-vue-grzybyfunkcjonalne-app.patch new file mode 100644 index 00000000..e8be90cb --- /dev/null +++ b/.planning/phases/33-seo-output-audit-and-content/33-13-vue-grzybyfunkcjonalne-app.patch @@ -0,0 +1,1529 @@ +diff --git a/app/components/SiteFooter.vue b/app/components/SiteFooter.vue +index f51bbbc..865a5b7 100644 +--- a/app/components/SiteFooter.vue ++++ b/app/components/SiteFooter.vue +@@ -1,5 +1,6 @@ + + +