docs(33-12): app patches for the release

- sm-demo-app: Journal, sitemap and SEO config, nginx without sitemap
  locations, env templates, DEPLOY.md SEO steps, seo-pages.csv,
  TestSEOOutput, scripts/smoke-seo.sh, release smoke on the new format
- vue-demo-app: seo.noindexRoutes for the decks, page meta for auth and
  signed-in pages, SeoSitemapLink in the footer, @nuxtjs/seo keys removed
- vue-summercmsio-app: changelog on seo.cms false, expectations for it
- sm-summercmsio-app: no change beyond the pin bumps
- 33-12-local-wiring.md: the scratch wiring for 33-13 and 33-14
This commit is contained in:
Jakub Zych
2026-11-04 18:02:08 +01:00
parent 617e15dcf0
commit e1d5110b29
5 changed files with 1537 additions and 0 deletions

View File

@@ -0,0 +1,202 @@
# 33-12 local wiring (P33-46)
How plan 33-12 ran the demo and summercms.io against the unreleased Phase 33 repositories, without pushing or tagging anything. 33-13 and 33-14 repeat these steps. Nothing here is committed to an application repository: the wiring (go.work, pnpm overrides, lockfiles, `plugins.gen.go`) stays in the scratch worktrees, and the patches carry only the changes that stay after the release (P33-47).
## Paths and ports
| What | Value |
|------|-------|
| Scratch directory | `/media/nvme/dev/golem15/summercms.io/scratch-33` (`$S`; outside every repository) |
| Sibling root | `/media/nvme/dev/golem15/summercms.io/summercms` (`$R`) |
| Demo Go binary | `127.0.0.1:8120` (dev.sh uses 8097; 8120 avoids a clash with a running dev stack) |
| Demo Nuxt server | `127.0.0.1:3020` (the dev.sh port; app.url and the site URLs point at it) |
| Scratch Postgres | `127.0.0.1:55561`, container `sm-demo-pg-scratch33`, postgres:17-alpine (sm-demo-pg on 55540 untouched) |
| 33-13's site | Nuxt `127.0.0.1:3016`, worktrees `$S/sm-grzybyfunkcjonalne-app` and `$S/vue-grzybyfunkcjonalne-app` (not touched by 33-12) |
Repository heads used: sm-demo-app da481c9, vue-demo-app 9a12701, sm-summercmsio-app ca06049, vue-summercmsio-app f36cbf1; sm-seo-plugin 49cc241, sm-sitemap-plugin 9e5c393, sm-journal-plugin 4f5bd19, vue-seo-layer 7af91fc, vue-journal-layer 1080a3f, vue-menu-layer 8b5da67, vue-faq-layer f7ec4fe, summercms.go at the 33-11 commits.
## Worktrees
```sh
S=/media/nvme/dev/golem15/summercms.io/scratch-33
R=/media/nvme/dev/golem15/summercms.io/summercms
mkdir -p $S
git -C $R/sm-demo-app worktree add --detach $S/sm-demo-app HEAD
git -C $R/sm-demo-app/vue-demo-app worktree add --detach $S/vue-demo-app HEAD
git -C $R/sm-summercmsio-app worktree add --detach $S/sm-summercmsio-app HEAD
git -C $R/sm-summercmsio-app/vue-summercmsio-app worktree add --detach $S/vue-summercmsio-app HEAD
```
A worktree's submodules are empty. The Go wiring points at the live checkouts' plugin submodules, and the Nuxt app lives in its own worktree. Tests that read the site from inside the Go repository need the empty directory replaced by a symlink for the run, and then restored before the patch is made:
```sh
rmdir $S/sm-demo-app/vue-demo-app && ln -s $S/vue-demo-app $S/sm-demo-app/vue-demo-app # scripts/smoke.sh
/bin/rm -f $S/sm-demo-app/vue-demo-app && mkdir $S/sm-demo-app/vue-demo-app # before git diff
```
The same applies to `$S/sm-summercmsio-app/vue-summercmsio-app` for `TestTerminalScript`.
## Go: sm-demo-app
`$S/sm-demo-app/go.work` replaces the committed one. It uses the live plugin submodules, except the three Phase 33 plugins, which come from the sibling checkouts. The SEO plugin needs the sitemap plugin's `contract` package, which the demo's pinned v0.1.2 submodule lacks.
```sh
cd $S/sm-demo-app
{
echo "go 1.27.0"; echo; echo "toolchain go1.27.0"; echo; echo "use ("; echo " ."
for p in chat chatbots chatchannels chatvideo faq feedback forum golem herald menu mfa moderation oauthserver pgstripe presentations quizzes sociallogin translate userfriends; do echo " $R/sm-demo-app/plugins/golem15/$p"; done
echo " $R/sm-journal-plugin"; echo " $R/sm-seo-plugin"; echo " $R/sm-sitemap-plugin"; echo ")"; echo
echo "replace git.golem15.com/golem15/summercms => $R/summercms.go"; echo
echo "replace ("
echo " git.golem15.com/golem15/sm-paymentgateway-plugin => $R/sm-demo-app/plugins/golem15/paymentgateway"
echo " git.golem15.com/golem15/sm-paymentgatewaysubscriptions-plugin => $R/sm-demo-app/plugins/golem15/paymentgatewaysubscriptions"
echo " git.golem15.com/golem15/sm-user-plugin => $R/sm-demo-app/plugins/golem15/user"
echo ")"
} > go.work
cp $R/sm-demo-app/go.work.sum .
(cd $R/summercms.go && go build -o $S/summer ./cmd/summer) # the CLI from the framework HEAD
$S/summer build # bin/demo (tags summercms_mcp); regenerates plugins.gen.go
```
`summer build` rewrites `plugins.gen.go` with the sibling versions (`v0.3.1-11-g4f5bd19` and the like). That file is wiring: 33-14 regenerates it on the real pins.
## Go: sm-summercmsio-app
Its `go.mod` replaces the plugins with `./plugins/golem15/*`, which are empty in a worktree. `go.work` replaces them at every version and uses only the app. Listing the plugins under `use` as well fails with "workspace module … is replaced at all versions".
```sh
cd $S/sm-summercmsio-app
cat > go.work <<EOF
go 1.27.0
toolchain go1.27.0
use .
replace git.golem15.com/golem15/summercms => $R/summercms.go
replace (
git.golem15.com/golem15/sm-journal-plugin => $R/sm-journal-plugin
git.golem15.com/golem15/sm-summercmsio-plugin => $R/sm-summercmsio-app/plugins/golem15/summercms
git.golem15.com/golem15/sm-translate-plugin => $R/sm-summercmsio-app/plugins/golem15/translate
git.golem15.com/golem15/sm-user-plugin => $R/sm-summercmsio-app/plugins/golem15/user
)
EOF
cp $R/sm-summercmsio-app/go.work.sum .
$S/summer build
```
Don't run `go build ./...` in the module root: it writes the binary `sm-summercmsio-app` there. The untracked `sm-summercmsio-app` file in the live checkout is such a binary.
## Nuxt: pnpm overrides
The same four lines go in each Nuxt worktree's `pnpm-workspace.yaml`. pnpm 12 reads `overrides:` there, and they apply at every depth, so the journal, menu and faq layers' own `link:../vue-seo-layer` resolves to the same checkout:
```sh
for d in $S/vue-demo-app $S/vue-summercmsio-app; do cat >> $d/pnpm-workspace.yaml <<EOF
overrides:
vue-seo-layer: link:$R/vue-seo-layer
vue-journal-layer: link:$R/vue-journal-layer
vue-menu-layer: link:$R/vue-menu-layer
vue-faq-layer: link:$R/vue-faq-layer
EOF
done
(cd $S/vue-demo-app && pnpm install && pnpm exec nuxt build)
export NUXT_PUBLIC_SUMMERCMS_VERSION=v0.6.1 # the summercms.io worktree has no ../go.mod
(cd $S/vue-summercmsio-app && pnpm install && pnpm run build:changelog && pnpm run test:changelog && pnpm run generate)
```
`pnpm install` rewrites `pnpm-lock.yaml` (wiring, left out of the patches). The build warns `Duplicated imports "JournalImageVariant"` (vue-journal-layer exports the type from two utils; harmless, deferred-items.md).
## The demo stack
The run variables live in `$S/demo-run.vars` (mode 0600, outside every repository). The tokens are generated per run and never printed (T-33-56):
```sh
rnd() { head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n'; }
(umask 077; cat > $S/demo-run.vars <<EOF
SUMMER_ENV=production
SUMMER_DATABASE__DSN=postgres://summercms_demo:summercms_demo@127.0.0.1:55561/summercms_demo?sslmode=disable
SUMMER_APP__KEY=$(head -c 32 /dev/urandom | base64)
SUMMER_ADMIN__JWT__SECRET=$(head -c 32 /dev/urandom | base64)
SUMMER_GOLEM15__USER__JWT__SECRET=$(head -c 32 /dev/urandom | base64)
SUMMER_GOLEM15__DEMO__VISITOR__EMAIL=visitor@example.test
SUMMER_GOLEM15__DEMO__VISITOR__PASSWORD=$(rnd | cut -c1-24)
SUMMER_GOLEM15__SEO__NOTFOUND_TOKEN=$(rnd)
SUMMER_GOLEM15__SEO__ROUTE_REPORT_TOKEN=$(rnd)
SUMMER_SITE_CACHE__PURGE_URL=http://127.0.0.1:3020/api/__site__/cache/purge
SUMMER_SITE_CACHE__PURGE_TOKEN=$(rnd)
SUMMER_APP__URL=http://127.0.0.1:3020
SUMMER_GOLEM15__SEO__SITE_URL=http://127.0.0.1:3020
SUMMER_GOLEM15__SITEMAP__SITE_URL=http://127.0.0.1:3020
SUMMER_MCP__RESOURCE=http://127.0.0.1:3020/mcp
SUMMER_GOLEM15__OAUTHSERVER__MCP__RESOURCE=http://127.0.0.1:3020/mcp
SUMMER_REALTIME__DRIVER=null
SUMMER_GOLEM15__USER__REGISTRATION__USE_REGISTER_THROTTLE=false
EOF
)
```
`SUMMER_ENV=production` matters. Outside production, the SEO plugin answers every page `noindex, nofollow` (staging lock), and the noindex checks would prove nothing. The local overrides replace the production overlay's demo.summercms.io addresses. A file named `.env` cannot be used from an agent session, because the secret-read guard blocks any command that touches it.
```sh
docker run -d --name sm-demo-pg-scratch33 -e POSTGRES_DB=summercms_demo -e POSTGRES_USER=summercms_demo \
-e POSTGRES_PASSWORD=summercms_demo -p 127.0.0.1:55561:5432 postgres:17-alpine
cd $S/sm-demo-app && mkdir -p logs storage/app/uploads/public
set -a; . $S/demo-run.vars; set +a
./bin/demo migrate
printf '%s\n' 'AdminDemo123!' | ./bin/demo admin:create --email admin@example.test --login admin --superuser
./bin/demo demo:seed # Journal posts with categories and Polish translations
bash $S/run-demo-go.sh # bin/demo serve --addr 127.0.0.1:8120 (pid in logs/go.pid)
bash $S/run-demo-nuxt.sh # node .output/server/index.mjs on 127.0.0.1:3020 (pid in $S/logs/demo-nuxt.pid)
./bin/demo sitemap:rebuild # after the Nuxt start, so the reported pages are listed
```
`run-demo-nuxt.sh` passes the Nuxt server, under `env -i`, every `NUXT_*_API_BASE` at `http://127.0.0.1:8120`, plus `NUXT_SEO_API_BASE=http://127.0.0.1:8120/_seo/api/v1`, `NUXT_SEO_BACKEND_BASE=http://127.0.0.1:8120`, `NUXT_SEO_NOTFOUND_TOKEN`, `NUXT_SEO_ROUTE_REPORT_TOKEN` and `NUXT_CACHE_PURGE_TOKEN`, all from the vars file. `stop-demo.sh` stops both servers. The page titles of `deploy/seo-pages.csv` were imported with `POST /_seo/api/v1/import` (`{"dataset": "records", "csv": …}`), as DEPLOY.md's "After the first release with Phase 33" describes.
## Checks
```sh
cd $S/sm-demo-app
SMOKE_SEO_API=http://127.0.0.1:8120 bash scripts/smoke-seo.sh http://127.0.0.1:3020 # smoke-seo: ok
go vet ./... && go test -short ./...
go test -tags summercms_mcp -count=1 ./... # Docker (testcontainers)
go test -tags summercms_mcp -run 'TestSEOOutput|TestSEOTabMounted|TestBootAllPlugins' -count=1 -v .
bash scripts/smoke.sh bin/demo # with the vue-demo-app symlink
bash scripts/check-deploy.sh
cd $S/vue-demo-app && pnpm test
```
The admin login is throttled to 5 attempts a minute per address. The smoke logs in once, so two runs in the same minute after other logins answer 429; wait a minute.
The SEO smoke is also kept outside the worktrees as `$S/33-12-smoke-seo.sh`, for 33-13 to reuse.
## Patches
```sh
cd $S/sm-demo-app && git add -N config/golem15.journal.yaml config/golem15.sitemap.yaml deploy/seo-pages.csv scripts/smoke-seo.sh seo_output_test.go
git diff --binary -- DEPLOY.md README.md config/golem15.journal.yaml config/golem15.seo.yaml config/golem15.sitemap.yaml \
deploy/env.example deploy/nginx/demo.summercms.io.conf deploy/seo-pages.csv deploy/site.env.example \
scripts/check-deploy.sh scripts/smoke-seo.sh scripts/smoke.sh seo_output_test.go > 33-12-sm-demo-app.patch
cd $S/vue-demo-app && git diff --binary -- $(git diff --name-only | grep -v '^pnpm-') > 33-12-vue-demo-app.patch
cd $S/vue-summercmsio-app && git diff --binary -- changelog/nuxt.config.ts changelog/testing/expect.txt > 33-12-vue-summercmsio-app.patch
printf '# no change beyond the pin bumps in 33-14\n' > 33-12-sm-summercmsio-app.patch
git -C $R/sm-demo-app apply --check 33-12-sm-demo-app.patch
git -C $R/sm-demo-app/vue-demo-app apply --check 33-12-vue-demo-app.patch
git -C $R/sm-summercmsio-app/vue-summercmsio-app apply --check 33-12-vue-summercmsio-app.patch
git -C $R/sm-summercmsio-app apply --check --allow-empty 33-12-sm-summercmsio-app.patch # without --allow-empty: "No valid patches in input"
```
## Cleanup
```sh
bash $S/stop-demo.sh
docker rm -f sm-demo-pg-scratch33
/bin/rm -f $S/demo-run.vars
git -C $R/sm-demo-app worktree remove --force $S/sm-demo-app
git -C $R/sm-demo-app/vue-demo-app worktree remove --force $S/vue-demo-app
git -C $R/sm-summercmsio-app worktree remove --force $S/sm-summercmsio-app
git -C $R/sm-summercmsio-app/vue-summercmsio-app worktree remove --force $S/vue-summercmsio-app
```
`--force` is needed because the worktrees hold the wiring and build output. The patches are committed before the removal.

View File

@@ -0,0 +1,955 @@
diff --git a/DEPLOY.md b/DEPLOY.md
index 19b6300..f2f4d0b 100644
--- a/DEPLOY.md
+++ b/DEPLOY.md
@@ -23,11 +23,12 @@ internet ──> Cloudflare (proxied, like summercms.io) ──443──> nginx
│ /_paymentgatewaysubscriptions/ /_presentations/ /_quizzes/
│ /_feedback/ /_demo/ /webhook/stripe/ /storage/app/uploads/public/
│ /_chat/ /_chatchannels/ /_chatbots/ /_chatvideo/ /_forum/ /_userfriends/
- │ = /api/realtime/token = /sitemap.xml = /sitemap.xsl
+ │ = /api/realtime/token = /robots.txt
│ = /mcp (unbuffered) /.well-known/oauth-protected-resource*
│ = /.well-known/oauth-authorization-server /oauth/mcp/
└── everything else (GET/HEAD) ...... 127.0.0.1:3098 summercms-demo-site (Nuxt SSR on Node)
│ server-side API calls to 127.0.0.1:8098
+ │ (also /sitemap.xml and its files, through vue-seo-layer)
bin/demo ── PostgreSQL 15 (database summercms_demo, role summercms_demo) ── storage/ (uploads)
└─ jobs in-process (Stripe webhooks); site-cache purge POST to 127.0.0.1:3098 (loopback only)
└─ publishes the phone remote's messages to Centrifugo's HTTP API on 127.0.0.1:8010
@@ -138,11 +139,12 @@ DBPW=$(openssl rand -hex 24)
printf "CREATE ROLE summercms_demo LOGIN PASSWORD '%s';\n" "$DBPW" | sudo -u postgres psql -v ON_ERROR_STOP=1 -q
sudo -u postgres createdb -O summercms_demo -E UTF8 summercms_demo
-# .env and site.env, root:summercms-demo 0640. The purge token is shared
-# (site.env names it NUXT_CACHE_PURGE_TOKEN, never NUXT_SITE_*: see Secrets).
+# .env and site.env, root:summercms-demo 0640. The purge token and the two
+# SEO tokens are shared (site.env names the purge token NUXT_CACHE_PURGE_TOKEN,
+# never NUXT_SITE_*: see Secrets).
sudo install -o root -g summercms-demo -m 0640 /dev/null /srv/summercms-demo/.env
sudo install -o root -g summercms-demo -m 0640 /dev/null /srv/summercms-demo/site.env
-PT=$(openssl rand -hex 32)
+PT=$(openssl rand -hex 32) NT=$(openssl rand -hex 32) RT=$(openssl rand -hex 32)
{
printf 'SUMMER_DATABASE__DSN=postgres://summercms_demo:%s@127.0.0.1:5432/summercms_demo?sslmode=disable\n' "$DBPW"
printf 'SUMMER_APP__KEY=%s\n' "$(openssl rand -base64 32)"
@@ -152,6 +154,8 @@ PT=$(openssl rand -hex 32)
printf 'SUMMER_GOLEM15__DEMO__VISITOR__PASSWORD=%s\n' "$(openssl rand -hex 12)"
printf 'SUMMER_SITE_CACHE__PURGE_URL=http://127.0.0.1:3098/api/__site__/cache/purge\n'
printf 'SUMMER_SITE_CACHE__PURGE_TOKEN=%s\n' "$PT"
+ printf 'SUMMER_GOLEM15__SEO__NOTFOUND_TOKEN=%s\n' "$NT"
+ printf 'SUMMER_GOLEM15__SEO__ROUTE_REPORT_TOKEN=%s\n' "$RT"
} | sudo tee /srv/summercms-demo/.env >/dev/null
{
grep -v '^#' <<'EOF'
@@ -160,12 +164,14 @@ NUXT_MENU_API_BASE=http://127.0.0.1:8098/_menu/api/v1
NUXT_FAQ_API_BASE=http://127.0.0.1:8098/_faq/api/v1
NUXT_PAYMENTGATEWAY_API_BASE=http://127.0.0.1:8098/_paymentgateway/api/v1
NUXT_PAYMENTGATEWAYSUBSCRIPTIONS_API_BASE=http://127.0.0.1:8098/_paymentgatewaysubscriptions/api/v1
-NUXT_PUBLIC_SITE_URL=https://demo.summercms.io
-NUXT_PUBLIC_SITEMAP_URL=/sitemap.xml
+NUXT_SEO_API_BASE=http://127.0.0.1:8098/_seo/api/v1
+NUXT_SEO_BACKEND_BASE=http://127.0.0.1:8098
EOF
printf 'NUXT_CACHE_PURGE_TOKEN=%s\n' "$PT"
+ printf 'NUXT_SEO_NOTFOUND_TOKEN=%s\n' "$NT"
+ printf 'NUXT_SEO_ROUTE_REPORT_TOKEN=%s\n' "$RT"
} | sudo tee /srv/summercms-demo/site.env >/dev/null
-unset DBPW PT
+unset DBPW PT NT RT
```
The visitor email comes from the build machine's secrets file, piped over SSH (never on a command
@@ -662,8 +668,8 @@ the 404 log, robots.txt with AI bot switches, history and the `seo_` MCP tools (
`golem15.seo.manage`). `config/golem15.seo.yaml` sets `site_url` to `https://demo.summercms.io`.
- **robots.txt moves to Go.** nginx sends `= /robots.txt` to the Go port (`deploy/nginx/demo.summercms.io.conf`);
- the Nuxt robots module no longer answers it. Before the first release with the plugin, copy the
- `Disallow` lines the Nuxt config writes today (`goPaths` in `vue-demo-app/nuxt.config.ts`: `/_user`,
+ the site has no Nuxt robots module. Before the first release with the plugin, copy the
+ `Disallow` lines the Nuxt config wrote (`goPaths` in `vue-demo-app/nuxt.config.ts`: `/_user`,
`/_journal`, `/_menu`, `/_faq`, `/_paymentgateway`, `/_paymentgatewaysubscriptions`, `/_feedback`,
`/storage`, `/_presentations`, `/_quizzes`, `/_demo`, `/api/realtime/token`, `/mcp`,
`/.well-known/oauth-protected-resource`, `/.well-known/oauth-authorization-server`, `/oauth/mcp/`) into
@@ -674,11 +680,100 @@ the 404 log, robots.txt with AI bot switches, history and the `seo_` MCP tools (
`Disallow: /_forum`, `Disallow: /_userfriends` and `Disallow: /livekit` under `User-agent: *`
(`goPaths` lists the six API prefixes). `/_herald` is not routed and needs no line.
`scripts/check-live.sh` checks the `/_chat` and `/_forum` lines.
-- **404 log.** Set `SUMMER_GOLEM15__SEO__NOTFOUND_TOKEN` in `.env` on rome (a random value, never printed
- or committed) and the same token in the Nuxt site's environment once the frontend layer reports 404s
- (Phase 33); without it the report endpoint stays off.
+- **The site's head comes from the plugin** (vue-seo-layer, through vue-journal-layer, vue-menu-layer and
+ vue-faq-layer). Every page is resolved with the plugin before Nuxt renders it: the title, description,
+ canonical, robots, Open Graph, hreflang and one JSON-LD `@graph` come from the answer, breadcrumbs and
+ pagination are rendered on the server, and redirects and 410s from **SEO › Redirects** apply on the
+ site. Browser navigations resolve through the Nuxt server (`/api/__seo__/resolve`), so `/_seo` has no
+ nginx location. `site.env` names the API (`NUXT_SEO_API_BASE`) and the Go origin of the root files
+ (`NUXT_SEO_BACKEND_BASE`), both on loopback (`deploy/site.env.example`).
+- **404 log and page registry.** `SUMMER_GOLEM15__SEO__NOTFOUND_TOKEN` and
+ `SUMMER_GOLEM15__SEO__ROUTE_REPORT_TOKEN` in `.env`, the same values as `NUXT_SEO_NOTFOUND_TOKEN` and
+ `NUXT_SEO_ROUTE_REPORT_TOKEN` in `site.env` (random, never printed or committed; "One-time server
+ setup" writes them, "Before the first release with Phase 33" below adds them to an existing install).
+ Each 404 page view lands in **SEO › 404 log**; at every start the site reports its static pages (`/`,
+ `/blog`, `/chat`, `/contact`, `/faq`, `/forum`, `/quizzes`), which appear in **SEO › Pages** and the
+ sitemaps. Without a token nothing is reported.
+- **Noindex pages.** The deck routes `/p/**` and `/presentations/**` render in the browser only and are
+ declared in `seo.noindexRoutes` (`vue-demo-app/nuxt.config.ts`; the layer's build guard refuses any other
+ browser-only route, D-70); the auth pages (login, register, connect, link-admin) and the signed-in pages
+ (account security, subscriptions, friends, calls, forum threads) set it in their page meta; quizzes and
+ the roadmap redirect keep `noindex`. All of them answer `X-Robots-Tag: noindex, nofollow` (`noindex`
+ for the two) and stay out of the route report.
+- **Sitemaps** (sm-sitemap-plugin). `/sitemap.xml` is a sitemap index of `/sitemap-<type>-<n>.xml`
+ files (posts, categories, authors, pages), which Nuxt serves from the Go port's `/_sitemap/files/` on
+ loopback: nginx has no `/sitemap*` or `/_sitemap` location (`scripts/check-deploy.sh` checks it) and
+ `/sitemap.xsl` is gone. The addresses start with `golem15.sitemap.site_url`
+ (`config/golem15.sitemap.yaml`, or **Settings › Sitemaps**); without one `/sitemap.xml` answers 404. The
+ demo has no tag pages (`config/golem15.journal.yaml`, `tag_pages: false`), so no tag address is listed.
+ Rebuilds run as jobs in the serve process after each Journal save and nightly. The footer links the
+ HTML sitemap (`/sitemap`, `/mapa-strony` in Polish).
+- **Page titles.** A reported page takes its name from its path ("Home", "Faq") until it has its own SEO
+ title; `deploy/seo-pages.csv` holds the titles and descriptions the pages had before (records by URL,
+ imported once, below).
- **Verify** after the release: `curl -s https://demo.summercms.io/robots.txt` shows the copied lines and
- the `Sitemap:` line; `curl -sI https://demo.summercms.io/robots.txt` answers 200 `text/plain`.
+ the `Sitemap:` line; `curl -sI https://demo.summercms.io/robots.txt` answers 200 `text/plain`; and from
+ the build machine `SMOKE_SEO_API=… scripts/smoke-seo.sh https://demo.summercms.io` (below) ends with
+ `smoke-seo: ok`.
+
+**Before the first release with Phase 33** (the SEO layer and the new sitemap format), on rome as root
+(`$RSH $ROME 'sudo bash -s'` with a quoted heredoc). It adds the two SEO tokens to both files and
+refuses when either already names one, never printing a value:
+
+```sh
+set -euo pipefail
+E=/srv/summercms-demo/.env S=/srv/summercms-demo/site.env
+n=$(cat "$E" "$S" | { grep -c -E '^(SUMMER_GOLEM15__SEO__(NOTFOUND|ROUTE_REPORT)_TOKEN|NUXT_SEO_)' || true; })
+[ "$n" = 0 ] || { echo "$n SEO keys are already set; edit the files by hand"; exit 1; }
+NT=$(openssl rand -hex 32) RT=$(openssl rand -hex 32)
+printf 'SUMMER_GOLEM15__SEO__NOTFOUND_TOKEN=%s\nSUMMER_GOLEM15__SEO__ROUTE_REPORT_TOKEN=%s\n' "$NT" "$RT" >>"$E"
+printf 'NUXT_SEO_API_BASE=http://127.0.0.1:8098/_seo/api/v1\nNUXT_SEO_BACKEND_BASE=http://127.0.0.1:8098\n' >>"$S"
+printf 'NUXT_SEO_NOTFOUND_TOKEN=%s\nNUXT_SEO_ROUTE_REPORT_TOKEN=%s\n' "$NT" "$RT" >>"$S"
+unset NT RT
+sed -i '/^NUXT_PUBLIC_SITE_URL=\|^NUXT_PUBLIC_SITEMAP_URL=/d' "$S" # read by nothing since the SEO layer
+```
+
+**After the first release with Phase 33** (the release restarted both programs and installed the vhost
+without the sitemap locations), on rome as root, the same way as "robots.txt without a browser" (the
+admin password through `jq` into a 0700 temp directory). The migration copied the old sitemap
+definition's url items into the additional addresses; the reported pages list `/`, `/blog` and `/faq`
+already and `/subscriptions` is noindex, so they are removed. Then the sitemaps are rebuilt once and the
+page titles imported:
+
+```sh
+set -euo pipefail; umask 077
+D=$(mktemp -d); trap 'rm -rf "$D"' EXIT; G=http://127.0.0.1:8098
+jq -R '{login:"admin",password:.}' </root/summercms-demo-admin.txt >"$D/login.json"
+curl -sf -o "$D/login.out" -X POST -H 'Content-Type: application/json' -d @"$D/login.json" $G/smbackend/api/v1/auth/login
+printf 'Authorization: Bearer %s\n' "$(jq -r .data.access_token "$D/login.out")" >"$D/hdr"
+curl -sf -o "$D/sitemap.json" -H @"$D/hdr" $G/_sitemap/api/v1/settings
+jq -c .data.settings.extra_urls "$D/sitemap.json" # the copied addresses, kept for the record
+jq '{extra_urls: [.data.settings.extra_urls[] | select(.url as $u | ["/", "/blog", "/faq", "/subscriptions"] | index($u) | not)]}' "$D/sitemap.json" >"$D/put.json"
+curl -s -o /dev/null -w '%{http_code}\n' -X PUT -H @"$D/hdr" -H 'Content-Type: application/json' -d @"$D/put.json" $G/_sitemap/api/v1/settings # 200
+(cd /srv/summercms-demo && sudo -u summercms-demo SUMMER_ENV=production ./bin/demo sitemap:rebuild) # Rebuilt the sitemaps: …
+jq -Rs '{dataset: "records", csv: .}' </tmp/seo-pages.csv >"$D/import.json"
+curl -s -o "$D/import.out" -w '%{http_code}\n' -H @"$D/hdr" -H 'Content-Type: application/json' -d @"$D/import.json" $G/_seo/api/v1/import # 200
+jq -c '{applied, summary}' "$D/import.out" # applied true, refused 0
+curl -s -o /dev/null -X POST -H @"$D/hdr" -H 'Content-Type: application/json' -d '{}' $G/smbackend/api/v1/auth/logout
+```
+
+`/tmp/seo-pages.csv` is `deploy/seo-pages.csv`, uploaded first
+(`rsync -av -e "$RSH" deploy/seo-pages.csv $ROME:/tmp/seo-pages.csv`). The import needs the route report
+first: the Nuxt site sends it right after its start (`[vue-seo-layer] route report: 7 routes sent` in its
+log).
+
+**smoke-seo.sh** checks the SEO output through the site the way a visitor and a crawler see it: a Journal
+post's CMS title, one canonical, hreflang, one JSON-LD `@graph` and breadcrumbs; the sitemap index, its
+files and their addresses (no duplicate, none noindex); robots.txt; the noindex routes and pages; the HTML
+sitemap; a redirect it creates through the SEO API (301 from Nuxt, removed again); the reported pages in
+the registry; and a 404 reaching the 404 log (removed again). Locally after `scripts/dev.sh up`:
+`scripts/smoke-seo.sh http://127.0.0.1:3020`. Against rome, from the build machine with the admin
+password in a 0600 file and the Go port reached over an SSH tunnel
+(`$RSH -N -L 18098:127.0.0.1:8098 $ROME &`):
+`SMOKE_SEO_API=http://127.0.0.1:18098 SMOKE_SEO_ADMIN_PASSWORD_FILE=… scripts/smoke-seo.sh https://demo.summercms.io`.
+An alternate or sitemap address that does not answer 200 is a `WARN` line (`SMOKE_SEO_STRICT=1` makes it
+a failure): the Polish addresses (`/pl/…`) the CMS names answer 404 while the site keeps the language in a
+cookie at one address (D-13).
## Build
diff --git a/README.md b/README.md
index 5439013..78aa580 100644
--- a/README.md
+++ b/README.md
@@ -7,7 +7,8 @@ a login (no public page links to it); `DEPLOY.md` covers the server setup, build
rollback.
- **Go** (`bin/demo`, 127.0.0.1:8097): the admin at `/smbackend` (`backend.uri` in
- `config/backend.yaml`), every plugin's API and `/sitemap.xml`.
+ `config/backend.yaml`), every plugin's API, robots.txt and the sitemaps the site serves at
+ `/sitemap.xml` (DEPLOY.md, "SEO").
- **Nuxt** (`vue-demo-app`, 127.0.0.1:3020): the guided tour home page, journal, FAQ, subscriptions,
registration and login (Turnstile), contact, presentations and quizzes, in English and Polish.
- **Postgres** (docker compose, 127.0.0.1:55540): database, user and password `summercms_demo`.
@@ -32,7 +33,7 @@ is a package of the root module.
| `golem15.moderation` | `plugins/golem15/moderation` (`sm-moderation-plugin`) | Banned words and links, edited in Settings; the demo refuses chat messages and forum posts that contain them |
| `golem15.translate` | `plugins/golem15/translate` | Locales and translatable content |
| `golem15.journal` | `plugins/golem15/journal` | Posts and categories (`/_journal/api/v1`) |
-| `golem15.sitemap` | `plugins/golem15/sitemap` | `/sitemap.xml` from a definition edited in Settings |
+| `golem15.sitemap` | `plugins/golem15/sitemap` | The sitemap index `/sitemap.xml` and its per-type files, built from the content and edited in Settings › Sitemaps |
| `golem15.menu` | `plugins/golem15/menu` | Menus (`/_menu/api/v1`) |
| `golem15.faq` | `plugins/golem15/faq` | FAQs (`/_faq/api/v1`) |
| `golem15.paymentgateway` | `plugins/golem15/paymentgateway` | Orders and payments (`/_paymentgateway/api/v1`) |
@@ -275,7 +276,8 @@ scripts/build.sh # release: bin/demo (linux/amd64) and vue-demo-app/.
scripts/smoke.sh # boot both on a fresh postgres:15 and check the APIs, every server-rendered page and the purge route (no purge token in any page)
scripts/check-pins.sh # every plugin submodule is on the version go.mod requires
scripts/build.sh dev # host build through go.work, no refusals
-scripts/check-deploy.sh # deploy/: nginx -t, supervisor parse, ports, /backend refused, /smbackend to Go, no /oauth or /api/ to Go (but the realtime token), Centrifugo checkconfig and ports, no NUXT_SITE_* key in site.env.example, the LiveKit program, ports and caps, the chat-family locations, no /_herald
+scripts/check-deploy.sh # deploy/: nginx -t, supervisor parse, ports, /backend refused, /smbackend to Go, no /oauth or /api/ to Go (but the realtime token), Centrifugo checkconfig and ports, no NUXT_SITE_* key in site.env.example, the LiveKit program, ports and caps, the chat-family locations, no /_herald, no /sitemap, /_sitemap or /_seo location
+scripts/smoke-seo.sh http://127.0.0.1:3020 # a running site's SEO output: a post's CMS head, one graph and breadcrumbs, the sitemap index and files, robots.txt, noindex routes and pages, the HTML sitemap, a redirect, the page registry and the 404 log (DEPLOY.md, "SEO")
scripts/check-live.sh https://demo.summercms.io # a deployed origin: banner, HSTS, /backend 404, /smbackend admin 200, API 200, /register Turnstile, /contact, Polish, token-less register 422, /roadmap, quizzes, realtime token 401, /ws upgrade, no link to the admin, no purge key in a page, /chat /forum /friends /call prompts, chat-family 401s, /livekit/rtc/validate from LiveKit, no /_herald, robots lines, Back in a browser
```
@@ -305,14 +307,16 @@ scripts/check-live.sh https://demo.summercms.io # a deployed origin: banner, H
`vue-demo-app/.output` on 127.0.0.1:13098 (`SMOKE_SITE_PORT`) against the binary and checks every
server-rendered page (`/`, `/blog`, a post, `/faq`, `/subscriptions`, `/register`, `/login`,
`/contact`, `/quizzes`: banner, test card and backend call to action present; no admin path and
- no visitor credential), the `/roadmap` redirect and the quiz list. The deck pages are client-only;
+ no visitor credential), the `/roadmap` redirect and the quiz list. It runs `sitemap:rebuild` and
+ checks the sitemap index on the binary and, through the site, `/sitemap.xml`, a sitemap file and
+ `/robots.txt`. The deck pages are client-only (an empty app root, `X-Robots-Tag: noindex, nofollow`);
`scripts/dev.sh snapshot --site` checks their compact banner (`deck-snapshot.cjs --expect-banner`).
The site runs with two throwaway purge tokens, `NUXT_CACHE_PURGE_TOKEN` and the deprecated
- `NUXT_SITE_CACHE_PURGE_TOKEN`: no server-rendered page and no deck page
- (`window.__NUXT_SITE_CONFIG__`) may carry either value or a purge key name (`cachePurgeToken`,
+ `NUXT_SITE_CACHE_PURGE_TOKEN`: no server-rendered page and no deck page may carry either value or a purge key name (`cachePurgeToken`,
`cacheAllowProxied`), the new token purges (200) and the deprecated one gets 401, and the site log
- has no value and no deprecation warning (the new name wins, so the old one is ignored). nuxt-site-config publishes every `NUXT_SITE_*` variable
- in the page payload, which is how the old name leaked before vue-journal-layer 0.6.0.
+ has no value and no deprecation warning (the new name wins, so the old one is ignored). nuxt-site-config
+ (gone with vue-journal-layer 0.8.0) published every `NUXT_SITE_*` variable in the page payload, which
+ is how the old name leaked before vue-journal-layer 0.6.0.
It ends with `smoke: ok`.
- **Local runs** export `SUMMER_ENV=local` (`scripts/dev.sh`), so the production overlay in
`config/env/production/` (debug off, the public origin, CORS, Stripe return URLs) applies only on
diff --git a/config/golem15.journal.yaml b/config/golem15.journal.yaml
new file mode 100644
index 0000000..c7edb31
--- /dev/null
+++ b/config/golem15.journal.yaml
@@ -0,0 +1,7 @@
+# golem15.journal (sm-journal-plugin): the demo's Journal.
+#
+# The site has no tag pages (vue-demo-app nuxt.config.ts: journal.routes.tag
+# is false), so tag URLs resolve to nothing: the sitemaps, the SEO plugin's
+# resolve and the menu item types never name /blog/tag/... addresses that
+# answer 404. Author pages stay on (/blog/author/:author).
+tag_pages: false
diff --git a/config/golem15.seo.yaml b/config/golem15.seo.yaml
index dbea376..b99938f 100644
--- a/config/golem15.seo.yaml
+++ b/config/golem15.seo.yaml
@@ -2,11 +2,20 @@
# URL rules, redirects, the 404 log, robots.txt and the SEO MCP tools.
#
# site_url is the public origin the Nuxt site serves; canonical URLs,
-# Open Graph URLs and robots.txt Sitemap lines are built from it, never from
-# a request's Host. The 404 report token comes from the environment
-# (SUMMER_GOLEM15__SEO__NOTFOUND_TOKEN, .env on rome; never committed);
-# without it the report endpoint is off.
+# Open Graph URLs, hreflang alternates, the JSON-LD graph and robots.txt
+# Sitemap lines are built from it, never from a request's Host. Locally,
+# override it with SUMMER_GOLEM15__SEO__SITE_URL (http://127.0.0.1:3020).
+#
+# Both tokens come from the environment (.env on rome; never committed) and
+# the Nuxt site gets the same values (site.env, NUXT_SEO_NOTFOUND_TOKEN and
+# NUXT_SEO_ROUTE_REPORT_TOKEN):
+# - notfound_token (SUMMER_GOLEM15__SEO__NOTFOUND_TOKEN): the 404 report;
+# empty turns the endpoint off.
+# - route_report_token (SUMMER_GOLEM15__SEO__ROUTE_REPORT_TOKEN): the route
+# report vue-seo-layer posts at server start, which lists the site's static
+# pages in SEO › Pages; empty falls back to notfound_token.
site_url: https://demo.summercms.io
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..1c09ca2
--- /dev/null
+++ b/config/golem15.sitemap.yaml
@@ -0,0 +1,15 @@
+# golem15.sitemap (sm-sitemap-plugin): the sitemap index at /sitemap.xml and
+# its per-type files /sitemap-<type>-<n>.xml, built from the Journal posts,
+# categories and authors, the SEO plugin's page registry and the additional
+# addresses (Settings › Sitemaps).
+#
+# site_url is the public origin every <loc> starts with (the Site address
+# setting wins when it is set); without either, /sitemap.xml answers 404. The
+# request's Host is never used. Locally, override it with
+# SUMMER_GOLEM15__SITEMAP__SITE_URL (http://127.0.0.1:3020).
+#
+# Rebuilds run as conga jobs in the serve process (config/queue.yaml
+# work_in_serve) after every Journal save and nightly; after a release that
+# changes the sitemap format, run `bin/demo sitemap:rebuild` once (DEPLOY.md,
+# "SEO").
+site_url: https://demo.summercms.io
diff --git a/deploy/env.example b/deploy/env.example
index cdf6238..ada8493 100644
--- a/deploy/env.example
+++ b/deploy/env.example
@@ -34,6 +34,12 @@ SUMMER_GOLEM15__PGSTRIPE__TEST_SUBSCRIPTION_WEBHOOK_SECRET=
# "Rotating the site-cache purge token").
SUMMER_SITE_CACHE__PURGE_URL=http://127.0.0.1:3098/api/__site__/cache/purge
SUMMER_SITE_CACHE__PURGE_TOKEN=
+# golem15.seo (config/golem15.seo.yaml): the 404 report token and the route
+# report token, the same values as NUXT_SEO_NOTFOUND_TOKEN and
+# NUXT_SEO_ROUTE_REPORT_TOKEN in site.env (openssl rand -hex 32). Empty: the
+# 404 log gets no reports and SEO › Pages lists no static pages.
+SUMMER_GOLEM15__SEO__NOTFOUND_TOKEN=
+SUMMER_GOLEM15__SEO__ROUTE_REPORT_TOKEN=
# Cloudflare Turnstile on register and login (D-07; golem15.user.turnstile is
# closed in config/golem15.user.yaml): the secret key of the Turnstile widget
# for demo.summercms.io, from the operator's secrets file. Without it every
diff --git a/deploy/nginx/demo.summercms.io.conf b/deploy/nginx/demo.summercms.io.conf
index 3dbedf2..20d4e56 100644
--- a/deploy/nginx/demo.summercms.io.conf
+++ b/deploy/nginx/demo.summercms.io.conf
@@ -3,8 +3,8 @@
# - summercms-demo, the Go binary, on 127.0.0.1:8098 (supervisor program
# summercms-demo): only the allow-listed prefixes below (the admin at
# /smbackend, plugin APIs including the chat family, the demo API, the
-# realtime token route, Stripe webhooks, public uploads, the sitemap,
-# the MCP endpoint and its OAuth paths);
+# realtime token route, Stripe webhooks, public uploads, robots.txt,
+# the MCP endpoint and its OAuth paths); the sitemaps go through Nuxt;
# - summercms-demo-centrifugo, the demo's own Centrifugo, on 127.0.0.1:8010
# (supervisor program summercms-demo-centrifugo, D-17): the WebSocket at
# /ws of the presentations' speaker view and phone remote, the chat, the
@@ -405,24 +405,11 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
}
- # The sitemap plugin's document and its stylesheet.
- location = /sitemap.xml {
- proxy_pass http://127.0.0.1:8098;
- 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 {
- proxy_pass http://127.0.0.1:8098;
- proxy_http_version 1.1;
- proxy_set_header Host $host;
- proxy_set_header X-Real-IP $remote_addr;
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
- proxy_set_header X-Forwarded-Proto $scheme;
- }
+ # The sitemaps (/sitemap.xml, the index, and its /sitemap-<type>-<n>.xml
+ # files), llms.txt and the verification files reach Nuxt through
+ # location /: vue-seo-layer fetches them from the Go port on loopback
+ # (/_sitemap/files/ and the SEO plugin's API), so neither /_sitemap nor
+ # /_seo has a location here (scripts/check-deploy.sh checks it).
# Nuxt: pages, /_nuxt assets, fonts. GET and HEAD only, so the layers' POST
# routes (the site-cache purge, called by Go on loopback) stay unreachable.
diff --git a/deploy/seo-pages.csv b/deploy/seo-pages.csv
new file mode 100644
index 0000000..c98c558
--- /dev/null
+++ b/deploy/seo-pages.csv
@@ -0,0 +1,3 @@
+url,locale,title,description
+/,en,SummerCMS Demo,A guided tour of SummerCMS: every shared Golem15 plugin on one Go binary.
+/faq,en,FAQ,"Managed in the admin under FAQ, one set per language; served by the FAQ API and rendered with vue-faq-layer."
diff --git a/deploy/site.env.example b/deploy/site.env.example
index 05b34c5..46d7b37 100644
--- a/deploy/site.env.example
+++ b/deploy/site.env.example
@@ -10,10 +10,18 @@ NUXT_PAYMENTGATEWAY_API_BASE=http://127.0.0.1:8098/_paymentgateway/api/v1
NUXT_PAYMENTGATEWAYSUBSCRIPTIONS_API_BASE=http://127.0.0.1:8098/_paymentgatewaysubscriptions/api/v1
# The demo's own API (golem15.demo): /roadmap and /quizzes read it on the server.
NUXT_DEMO_API_BASE=http://127.0.0.1:8098/_demo/api/v1
-# The public origin (canonical URLs, sitemap and robots of the SEO module) and
-# the footer's sitemap link (nginx sends /sitemap.xml to Go).
-NUXT_PUBLIC_SITE_URL=https://demo.summercms.io
-NUXT_PUBLIC_SITEMAP_URL=/sitemap.xml
+# vue-seo-layer: the SEO plugin's API (resolve, site answer, 404 and route
+# reports) and the Go origin of the root files (robots.txt, the sitemaps);
+# the head, canonical and sitemap URLs come from golem15.seo.site_url and
+# golem15.sitemap.site_url on the binary, never from here.
+NUXT_SEO_API_BASE=http://127.0.0.1:8098/_seo/api/v1
+NUXT_SEO_BACKEND_BASE=http://127.0.0.1:8098
+# The same values as SUMMER_GOLEM15__SEO__NOTFOUND_TOKEN and
+# SUMMER_GOLEM15__SEO__ROUTE_REPORT_TOKEN in .env (openssl rand -hex 32,
+# generated on rome, never printed): the 404 report and the route report of
+# the static pages to SEO › Pages. Empty: nothing is reported.
+NUXT_SEO_NOTFOUND_TOKEN=
+NUXT_SEO_ROUTE_REPORT_TOKEN=
# Site cache purge token: the same value as SUMMER_SITE_CACHE__PURGE_TOKEN in
# .env, generated on rome (openssl rand -hex 32) and never printed. Fewer
# than 32 characters disables the purge route. The name is deliberately not
diff --git a/scripts/check-deploy.sh b/scripts/check-deploy.sh
index 417cbc5..0d7d801 100755
--- a/scripts/check-deploy.sh
+++ b/scripts/check-deploy.sh
@@ -39,6 +39,8 @@
# - each chat-family prefix (/_chat/, /_chatchannels/, /_chatbots/,
# /_chatvideo/, /_forum/, /_userfriends/) is a location to the Go port,
# and no location routes /_herald (D-16: no subscribe UI);
+# - no location routes /_seo, /_sitemap or /sitemap*: vue-seo-layer serves
+# the sitemaps through Nuxt from the Go port on loopback;
# - livekit.yaml.example holds placeholder keys only, and env.example
# declares the LiveKit and VAPID keys empty.
# Each LiveKit and chat-family mismatch prints its own line.
@@ -405,6 +407,10 @@ for prefix in ("_chat", "_chatchannels", "_chatbots", "_chatvideo", "_forum", "_
for loc in re.findall(r"location\s+(?:[=~^*]+\s+)?(\S+)\s*\{", code):
if loc.startswith("/_herald"):
problems.append(f"nginx routes {loc}: /_herald stays unrouted (D-16, no subscribe UI)")
+ # vue-seo-layer serves the sitemaps from the Go port on loopback and
+ # resolves through the Nuxt server (DEPLOY.md, "SEO").
+ if loc.startswith(("/_seo", "/_sitemap", "/sitemap")):
+ problems.append(f"nginx routes {loc}: the sitemaps and the SEO API reach Go through Nuxt (vue-seo-layer), not nginx")
if problems:
sys.exit("\n".join(problems))
diff --git a/scripts/smoke-seo.sh b/scripts/smoke-seo.sh
new file mode 100755
index 0000000..55af385
--- /dev/null
+++ b/scripts/smoke-seo.sh
@@ -0,0 +1,289 @@
+#!/usr/bin/env bash
+# The SEO output of a running demo, checked through its Nuxt site the way a
+# visitor and a crawler see it (Phase 33: vue-seo-layer, golem15.seo,
+# golem15.sitemap):
+#
+# scripts/smoke-seo.sh BASE_URL e.g. http://127.0.0.1:3020
+#
+# - a Journal post: the CMS title, one canonical, hreflang, one JSON-LD
+# @graph, server-rendered breadcrumbs and the CMS's X-Robots-Tag;
+# - /sitemap.xml is a sitemap index whose files answer through the site, and
+# robots.txt names it;
+# - a redirect created through the SEO API answers 301 from Nuxt (removed
+# again at the end);
+# - the declared browser-only routes (/p/**, /presentations/**) and the auth
+# and signed-in pages answer noindex, nofollow;
+# - the route report put the static pages into the page registry, and a 404
+# reaches the 404 log (the entry is removed again);
+# - the HTML sitemap page and the footer link to it.
+#
+# The API checks talk to the Go binary directly:
+# SMOKE_SEO_API Go origin (default http://127.0.0.1:8097, scripts/dev.sh)
+# SMOKE_SEO_ADMIN_LOGIN admin login (default admin)
+# SMOKE_SEO_ADMIN_PASSWORD_FILE a file holding the admin password, else
+# SMOKE_SEO_ADMIN_PASSWORD the password (default: scripts/dev.sh's local admin)
+# SMOKE_SEO_POST the post path to check (default: the first post
+# with a category, from the Journal API)
+# SMOKE_SEO_STRICT=1 turn WARN lines (alternates and sitemap
+# addresses that do not answer 200) into failures
+#
+# Prints "ok ..." per check, "WARN ..." and "FAIL ..." lines, and ends with
+# "smoke-seo: ok" (exit 0) or "smoke-seo: N failed" (exit 1). No secret is
+# printed or passed on a command line.
+set -euo pipefail
+
+[ $# -eq 1 ] || { echo "usage: scripts/smoke-seo.sh BASE_URL" >&2; exit 2; }
+BASE=${1%/}
+API=${SMOKE_SEO_API:-http://127.0.0.1:8097}
+API=${API%/}
+STRICT=${SMOKE_SEO_STRICT:-0}
+command -v python3 >/dev/null || { echo "smoke-seo: python3 not found" >&2; exit 2; }
+
+D=$(mktemp -d)
+HDR="$D/auth.hdr"
+trap 'logout; rm -rf "$D"' EXIT
+failed=0
+ok() { echo "ok $*"; }
+fail() { echo "FAIL $*"; failed=$((failed + 1)); }
+warn() {
+ if [ "$STRICT" = 1 ]; then fail "$*"; else echo "WARN $*"; fi
+}
+
+# get PATH_OR_URL NAME: GET without following redirects into $D/NAME.body and
+# $D/NAME.head; prints the status.
+get() {
+ local url=$1
+ case "$url" in http://* | https://*) ;; *) url="$BASE$url" ;; esac
+ curl -s -o "$D/$2.body" -D "$D/$2.head" -w '%{http_code}' -H 'Accept: text/html' "$url" || echo 000
+}
+# probe PATH: the status of PATH without Accept: text/html, so the layer
+# does not report a 404 to the 404 log (alternates and sitemap addresses);
+# the headers land in $D/probe.head.
+probe() { curl -s -o /dev/null -D "$D/probe.head" -w '%{http_code}' -H 'Accept: */*' "$BASE$1" || echo 000; }
+header() { tr -d '\r' <"$D/$1.head" | awk -v k="$(echo "$2" | tr 'A-Z' 'a-z')" -F': ' 'tolower($1)==k {sub(/^[^:]*: /, ""); print; exit}'; }
+count() { { grep -o -- "$2" "$D/$1.body" || true; } | wc -l | tr -d ' '; }
+# py NAME SCRIPT: runs SCRIPT with the body of NAME on stdin.
+py() { python3 -c "$2" <"$D/$1.body"; }
+
+# --- the admin token (redirect, registry and 404 log checks) ----------------
+login() {
+ local pw
+ if [ -n "${SMOKE_SEO_ADMIN_PASSWORD_FILE:-}" ]; then
+ pw=$(head -n1 "$SMOKE_SEO_ADMIN_PASSWORD_FILE")
+ else
+ pw=${SMOKE_SEO_ADMIN_PASSWORD:-AdminDemo123!}
+ fi
+ SMOKE_LOGIN=${SMOKE_SEO_ADMIN_LOGIN:-admin} SMOKE_PW="$pw" python3 -c '
+import json, os
+print(json.dumps({"login": os.environ["SMOKE_LOGIN"], "password": os.environ["SMOKE_PW"]}))' >"$D/login.json"
+ unset pw
+ LOGIN_STATUS=$(umask 077 && curl -s -o "$D/login.out" -w '%{http_code}' -X POST -H 'Content-Type: application/json' \
+ -d @"$D/login.json" "$API/smbackend/api/v1/auth/login" || echo 000)
+ rm -f "$D/login.json"
+ python3 -c '
+import json, sys
+try:
+ print("Authorization: Bearer " + json.load(open(sys.argv[1]))["data"]["access_token"])
+except Exception:
+ sys.exit(1)' "$D/login.out" >"$HDR" 2>/dev/null || { rm -f "$HDR"; return 1; }
+ rm -f "$D/login.out"
+}
+logout() {
+ [ -f "$HDR" ] || return 0
+ curl -s -o /dev/null -X POST -H @"$HDR" -H 'Content-Type: application/json' -d '{}' "$API/smbackend/api/v1/auth/logout" || true
+ rm -f "$HDR"
+}
+api() { # METHOD PATH [JSON_FILE] -> body in $D/api.out, prints the status
+ local args=(-s -o "$D/api.out" -w '%{http_code}' -X "$1" -H @"$HDR")
+ [ $# -ge 3 ] && args+=(-H 'Content-Type: application/json' -d @"$3")
+ curl "${args[@]}" "$API$2" || echo 000
+}
+
+# --- 1. a Journal post ----------------------------------------------------
+POST=${SMOKE_SEO_POST:-}
+if [ -z "$POST" ]; then
+ curl -s -o "$D/posts.json" "$API/_journal/api/v1/posts" || true
+ POST=$(python3 -c '
+import json, sys
+try:
+ posts = json.load(open(sys.argv[1]))["data"]
+except Exception:
+ sys.exit(0)
+for p in posts:
+ if p.get("categories"):
+ print("/blog/" + p["slug"]); break
+else:
+ if posts: print("/blog/" + posts[0]["slug"])' "$D/posts.json")
+fi
+[ -n "$POST" ] || { fail "no Journal post found through $API/_journal/api/v1/posts (set SMOKE_SEO_POST)"; POST=/blog/missing; }
+
+curl -s -o "$D/resolve.json" "$API/_seo/api/v1/resolve?url=$POST" || true
+CMS_TITLE=$(python3 -c '
+import json, sys
+try:
+ a = json.load(open(sys.argv[1]))
+ print(a["seo"]["title"] if a.get("action") == "ok" else "")
+except Exception:
+ print("")' "$D/resolve.json")
+CMS_XRT=$(python3 -c '
+import json, sys
+try: print(json.load(open(sys.argv[1]))["seo"]["x_robots_tag"])
+except Exception: print("")' "$D/resolve.json")
+
+st=$(get "$POST" post)
+[ "$st" = 200 ] && ok "$POST answers 200" || fail "$POST answers $st, not 200"
+n=$(count post '<script type="application/ld+json"'); [ "$n" = 1 ] && ok "$POST has one ld+json script" || fail "$POST has $n ld+json scripts, not 1"
+py post 'import re, sys, json
+m = re.search(r"<script type=\"application/ld\+json\"[^>]*>(.*?)</script>", sys.stdin.read(), re.S)
+g = json.loads(m.group(1)) if m else {}
+sys.exit(0 if isinstance(g.get("@graph"), list) and len(g["@graph"]) > 1 else 1)' &&
+ ok "$POST ld+json is one @graph" || fail "$POST ld+json is not one @graph with nodes"
+n=$(count post 'rel="canonical"'); [ "$n" = 1 ] && ok "$POST has one canonical" || fail "$POST has $n canonical links, not 1"
+n=$(count post 'hreflang="'); [ "$n" -ge 2 ] && ok "$POST has $n hreflang links" || fail "$POST has $n hreflang links, fewer than 2"
+n=$(count post 'class="seo-breadcrumbs__link"')
+grep -q '<nav class="seo-breadcrumbs"' "$D/post.body" && [ "$n" -ge 1 ] &&
+ ok "$POST has nav.seo-breadcrumbs ($n links)" || fail "$POST has no server-rendered nav.seo-breadcrumbs with links"
+title=$(py post 'import re, sys, html
+m = re.search(r"<title>(.*?)</title>", sys.stdin.read(), re.S)
+print(html.unescape(m.group(1)).strip() if m else "")')
+if [ -n "$CMS_TITLE" ] && [ "$title" = "$CMS_TITLE" ]; then ok "$POST title is the CMS title ($title)"; else fail "$POST title '$title' is not the CMS title '$CMS_TITLE'"; fi
+xrt=$(header post X-Robots-Tag)
+[ -n "$CMS_XRT" ] && [ "$xrt" = "$CMS_XRT" ] && ok "$POST X-Robots-Tag is the CMS's ($xrt)" || fail "$POST X-Robots-Tag '$xrt' is not the CMS's '$CMS_XRT'"
+
+# The alternates should answer 200 on this site.
+for alt in $(py post 'import re, sys
+for m in re.finditer(r"<link rel=\"alternate\" hreflang=\"[^\"]+\" href=\"([^\"]+)\"", sys.stdin.read()):
+ print(m.group(1))' | sort -u); do
+ path=$(python3 -c 'import sys, urllib.parse as u; p = u.urlsplit(sys.argv[1]); print(p.path + ("?" + p.query if p.query else ""))' "$alt")
+ st=$(probe "$path")
+ [ "$st" = 200 ] && ok "alternate $path answers 200" || warn "alternate $path answers $st"
+done
+
+# --- 2. sitemaps and robots.txt ---------------------------------------------
+st=$(get /sitemap.xml index)
+if [ "$st" = 200 ] && grep -q '<sitemapindex' "$D/index.body"; then
+ ok "/sitemap.xml is a sitemap index ($(header index Content-Type))"
+else
+ fail "/sitemap.xml answers $st without <sitemapindex>"
+fi
+files=$(py index 'import re, sys, urllib.parse as u
+for m in re.findall(r"<loc>([^<]+)</loc>", sys.stdin.read()):
+ print(u.urlsplit(m).path)')
+[ -n "$files" ] || fail "/sitemap.xml lists no sitemap file"
+addresses=0
+bad=0
+for f in $files; do
+ st=$(get "$f" file)
+ if [ "$st" = 200 ] && grep -q '<urlset' "$D/file.body"; then
+ ok "$f answers a urlset through the site"
+ else
+ fail "$f answers $st without <urlset>"
+ continue
+ fi
+ for loc in $(py file 'import re, sys, urllib.parse as u
+for m in re.findall(r"<loc>([^<]+)</loc>", sys.stdin.read()):
+ print(u.urlsplit(m).path)' | head -200); do
+ addresses=$((addresses + 1))
+ echo "$loc" >>"$D/locs.txt"
+ st=$(probe "$loc")
+ [ "$st" = 200 ] || { warn "sitemap address $loc answers $st"; bad=$((bad + 1)); }
+ case "$(header probe X-Robots-Tag)" in
+ *noindex*) fail "sitemap address $loc answers X-Robots-Tag: $(header probe X-Robots-Tag)" ;;
+ esac
+ done
+done
+[ "$addresses" -gt 0 ] && ok "$addresses sitemap addresses fetched, $bad not 200" || fail "the sitemap files list no address"
+dups=$( [ -f "$D/locs.txt" ] && sort "$D/locs.txt" | uniq -d | tr '\n' ' ')
+[ -z "$dups" ] && ok "no address is listed twice in the sitemaps" || fail "addresses listed more than once in the sitemaps: $dups"
+st=$(get /robots.txt robots)
+if [ "$st" = 200 ] && grep -q '^Sitemap: .*/sitemap\.xml$' "$D/robots.body"; then
+ ok "/robots.txt names the sitemap index"
+else
+ fail "/robots.txt answers $st without a Sitemap: .../sitemap.xml line"
+fi
+
+# --- 3. noindex routes, auth and signed-in pages ------------------------------
+for p in /p/smoke-seo /presentations/new /login /register /account/security; do
+ st=$(get "$p" noindex)
+ xrt=$(header noindex X-Robots-Tag)
+ [ "$xrt" = "noindex, nofollow" ] && ok "$p ($st) sends X-Robots-Tag: noindex, nofollow" ||
+ fail "$p ($st) sends X-Robots-Tag '$xrt', not 'noindex, nofollow'"
+done
+get /login noindex >/dev/null
+grep -q '<meta name="robots" content="noindex, nofollow">' "$D/noindex.body" &&
+ ok "/login has the robots meta noindex, nofollow" || fail "/login has no robots meta noindex, nofollow"
+
+# --- 4. the HTML sitemap and the footer link ---------------------------------
+st=$(get / home)
+grep -q 'class="seo-sitemap-link"' "$D/home.body" && ok "/ ($st) links the HTML sitemap in the footer" || fail "/ ($st) has no a.seo-sitemap-link"
+href=$(py home 'import re, sys
+m = re.search(r"<a href=\"([^\"]+)\"[^>]*class=\"seo-sitemap-link\"|class=\"seo-sitemap-link\"[^>]*href=\"([^\"]+)\"", sys.stdin.read())
+print((m.group(1) or m.group(2)) if m else "")')
+if [ -n "$href" ]; then
+ st=$(get "$href" html)
+ [ "$st" = 200 ] && grep -q "href=\"$POST\"" "$D/html.body" && ok "$href lists $POST" || fail "$href answers $st without a link to $POST"
+fi
+
+# --- 5. through the SEO API: a redirect, the registry, the 404 log ----------
+if login; then
+ src="/smoke-seo-old-$(date +%s)-$$"
+ printf '{"source":"%s","target":"%s","status":301,"note":"scripts/smoke-seo.sh"}' "$src" "$POST" >"$D/redirect.json"
+ st=$(api POST /_seo/api/v1/redirects "$D/redirect.json")
+ rid=$(python3 -c 'import json, sys
+try: print(json.load(open(sys.argv[1]))["data"]["id"])
+except Exception: print("")' "$D/api.out")
+ if [ -n "$rid" ]; then
+ st=$(get "$src" redirect)
+ loc=$(header redirect Location)
+ [ "$st" = 301 ] && [ "${loc%"$POST"}" != "$loc" ] && ok "$src answers 301 to $loc (SEO plugin redirect $rid)" ||
+ fail "$src answers $st with Location '$loc', not 301 to $POST"
+ st=$(api DELETE "/_seo/api/v1/redirects/$rid")
+ case "$st" in 2*) ;; *) fail "could not delete redirect $rid ($st)" ;; esac
+ else
+ fail "creating a redirect through the SEO API answered $st"
+ fi
+
+ st=$(api GET '/_seo/api/v1/pages?per_page=200')
+ python3 -c '
+import json, sys
+rows = json.load(open(sys.argv[1]))["data"]
+urls = {r["url"]: r for r in rows}
+need = ["/", "/faq", "/blog"]
+missing = [u for u in need if u not in urls or not urls[u].get("reported_at")]
+leaked = [u for u in ("/login", "/register", "/account/security", "/subscriptions") if u in urls]
+if missing or leaked:
+ print("missing=%s leaked=%s" % (missing, leaked)); sys.exit(1)
+print(" ".join(sorted(u for u, r in urls.items() if r.get("origin") == "reported")))' "$D/api.out" >"$D/registry.txt" 2>&1 &&
+ ok "the registry lists the reported routes: $(cat "$D/registry.txt")" ||
+ fail "the registry ($st) lacks reported routes or lists noindex pages: $(cat "$D/registry.txt")"
+
+ miss="/smoke-seo-missing-$(date +%s)-$$"
+ st=$(get "$miss" missing)
+ [ "$st" = 404 ] && ok "$miss answers 404" || fail "$miss answers $st, not 404"
+ nid=""
+ for _ in 1 2 3 4 5 6 7 8 9 10; do
+ api GET "/_seo/api/v1/notfound?search=$miss" >/dev/null
+ nid=$(python3 -c 'import json, sys
+try:
+ rows = [r for r in json.load(open(sys.argv[1]))["data"] if r.get("url") == sys.argv[2]]
+ print(rows[0]["id"] if rows else "")
+except Exception: print("")' "$D/api.out" "$miss")
+ [ -n "$nid" ] && break
+ sleep 0.5
+ done
+ if [ -n "$nid" ]; then
+ ok "$miss reached the 404 log (entry $nid)"
+ api DELETE "/_seo/api/v1/notfound/$nid" >/dev/null
+ else
+ fail "$miss did not reach the 404 log (is NUXT_SEO_NOTFOUND_TOKEN the backend's notfound_token?)"
+ fi
+else
+ fail "admin login at $API/smbackend/api/v1/auth/login answered ${LOGIN_STATUS:-000} (429: the admin login throttle, wait a minute): no redirect, registry or 404 log checks"
+fi
+
+if [ "$failed" -eq 0 ]; then
+ echo "smoke-seo: ok"
+else
+ echo "smoke-seo: $failed failed"
+ exit 1
+fi
diff --git a/scripts/smoke.sh b/scripts/smoke.sh
index e2bec68..c344ec7 100755
--- a/scripts/smoke.sh
+++ b/scripts/smoke.sh
@@ -1,14 +1,16 @@
#!/usr/bin/env bash
# Boot the built binary on a fresh postgres:15 the way a release does
-# (migrate, admin:create, demo:seed, serve), check the plugin APIs over HTTP,
-# then boot the built Nuxt site (vue-demo-app/.output) against that binary and
-# check its pages.
+# (migrate, admin:create, demo:seed, sitemap:rebuild, serve), check the plugin
+# APIs and the sitemap index over HTTP, then boot the built Nuxt site
+# (vue-demo-app/.output) against that binary and check its pages and the root
+# files it serves from the binary (vue-seo-layer: /sitemap.xml, a sitemap
+# file, /robots.txt).
#
# The site starts with two throwaway purge tokens: NUXT_CACHE_PURGE_TOKEN and
-# the deprecated NUXT_SITE_CACHE_PURGE_TOKEN (vue-journal-layer 0.6.0 still
-# reads it for one release). nuxt-site-config publishes every NUXT_SITE_*
-# variable in the page payload, so no server-rendered page and no
-# client-only page (window.__NUXT_SITE_CONFIG__) may carry either value or a
+# the deprecated NUXT_SITE_CACHE_PURGE_TOKEN (still read for one release).
+# nuxt-site-config published every NUXT_SITE_* variable in the page payload
+# until vue-journal-layer 0.8.0 dropped it, so no server-rendered page and no
+# client-only page may carry either value or a
# purge key name; the new token must purge (200) and the deprecated one,
# shadowed by it, must get 401; the site log carries no token value and no
# deprecation warning (the layer warns only when a setting comes from an old
@@ -132,6 +134,11 @@ run demo:seed >"$TMP/seed.log" 2>&1 || {
cat "$TMP/seed.log" >&2
fail "demo:seed failed"
}
+# The sitemap files exist after the first rebuild (DEPLOY.md, "SEO").
+run sitemap:rebuild >"$TMP/sitemap.log" 2>&1 || {
+ cat "$TMP/sitemap.log" >&2
+ fail "sitemap:rebuild failed"
+}
free_port "$PORT" SMOKE_PORT
(cd "$WORK" && exec env -i PATH="$PATH" HOME="$TMP" SUMMER_ENV=production "$BIN" serve --addr "127.0.0.1:$PORT") >"$TMP/serve.log" 2>&1 &
@@ -169,7 +176,12 @@ check() {
# The plugin APIs the site and the decks read.
check "$BASE/_journal/api/v1/posts" 200 application/json
check "$BASE/_faq/api/v1/faqs/home" 200 application/json
+# The sitemap index on the public origin (config/golem15.sitemap.yaml), its
+# files under /_sitemap/files/ (the site maps the root names).
check "$BASE/sitemap.xml" 200
+grep -qF '<loc>https://demo.summercms.io/sitemap-posts-1.xml</loc>' "$TMP/body" || fail "GET $BASE/sitemap.xml: not an index listing sitemap-posts-1.xml"
+check "$BASE/_sitemap/files/sitemap-posts-1.xml" 200
+grep -qF '/blog/welcome-to-summercms</loc>' "$TMP/body" || fail "GET $BASE/_sitemap/files/sitemap-posts-1.xml: no seeded post"
HASH="$(psql "SELECT public_hash FROM golem15_presentations_presentations WHERE slug = 'roadmap' AND is_published")"
[[ -n "$HASH" ]] || fail "demo:seed published no roadmap deck"
check "$BASE/_presentations/api/v1/public/roadmap-$HASH" 200 application/json
@@ -197,6 +209,7 @@ free_port "$SITE_PORT" SMOKE_SITE_PORT
NUXT_PAYMENTGATEWAY_API_BASE="$BASE/_paymentgateway/api/v1" \
NUXT_PAYMENTGATEWAYSUBSCRIPTIONS_API_BASE="$BASE/_paymentgatewaysubscriptions/api/v1" \
NUXT_DEMO_API_BASE="$BASE/_demo/api/v1" \
+ NUXT_SEO_API_BASE="$BASE/_seo/api/v1" \
NUXT_CACHE_PURGE_TOKEN="$PURGE_TOKEN" \
NUXT_SITE_CACHE_PURGE_TOKEN="$OLD_PURGE_TOKEN" \
node "$SITE_OUT/server/index.mjs") >"$TMP/site.log" 2>&1 &
@@ -217,6 +230,12 @@ fi
check "$SITE/" 200 text/html
+# vue-seo-layer serves the root files from the binary (NUXT_SEO_API_BASE).
+check "$SITE/sitemap.xml" 200 application/xml
+grep -qF '<sitemapindex' "$TMP/body" || fail "GET $SITE/sitemap.xml: not a sitemap index"
+check "$SITE/sitemap-posts-1.xml" 200 application/xml
+check "$SITE/robots.txt" 200 text/plain
+
# no_purge_leak LABEL fails when the last body carries a purge token value or
# a purge key name (cachePurgeToken, cacheAllowProxied...). Never prints a
# value; a key name match never spans one.
@@ -253,7 +272,10 @@ done
check "$SITE/roadmap" 302
grep -qi "^location: /p/roadmap-$HASH" "$TMP/headers" || fail "GET $SITE/roadmap: not a redirect to /p/roadmap-$HASH"
check "$SITE/p/roadmap-$HASH" 200 text/html
-grep -qF '__NUXT_SITE_CONFIG__' "$TMP/body" || fail "GET $SITE/p/roadmap-$HASH: no window.__NUXT_SITE_CONFIG__ (is the deck page still client-only?)"
+grep -qF '<div id="__nuxt"></div>' "$TMP/body" || fail "GET $SITE/p/roadmap-$HASH: the app root is rendered (is the deck page still client-only?)"
+# The deck routes render in the browser only, so seo.noindexRoutes declares
+# them (vue-seo-layer, D-70): served with X-Robots-Tag noindex, nofollow.
+tr -d '\r' <"$TMP/headers" | grep -qix 'x-robots-tag: noindex, nofollow' || fail "GET $SITE/p/roadmap-$HASH: no X-Robots-Tag: noindex, nofollow"
no_purge_leak "$SITE/p/roadmap-$HASH"
check "$SITE/quizzes" 200 text/html
grep -qF 'SummerCMS basics' "$TMP/body" || fail "GET $SITE/quizzes: the quiz list is not rendered"
diff --git a/seo_output_test.go b/seo_output_test.go
new file mode 100644
index 0000000..901ef44
--- /dev/null
+++ b/seo_output_test.go
@@ -0,0 +1,151 @@
+package main
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "slices"
+ "strings"
+ "testing"
+
+ "git.golem15.com/golem15/summercms/modules/surf"
+)
+
+// demoSite is the public origin config/golem15.seo.yaml and
+// config/golem15.sitemap.yaml name.
+const demoSite = "https://demo.summercms.io"
+
+// TestSEOOutput checks the SEO output the Nuxt site reads from the demo
+// binary (Phase 33, SC-1/SC-2) on the seeded content: /sitemap.xml is a
+// sitemap index of per-type files on the public origin and a file lists
+// the Journal posts without tag addresses (golem15.journal.tag_pages is
+// false); the SEO plugin's site answer names the origin, the locales and
+// the HTML sitemap; a seeded post resolves with one JSON-LD @graph and
+// breadcrumbs through its category; and the route report is accepted with
+// golem15.seo.route_report_token.
+func TestSEOOutput(t *testing.T) {
+ skipUnlessSEOMounted(t)
+ const reportToken = "seo-output-test-route-report-token"
+ demo := bootDemo(t, visitorEnv, map[string]any{"golem15.seo.route_report_token": reportToken})
+ mustSeed(t, demo)
+ if out, err := runCommand(t, demo, "sitemap:rebuild"); err != nil {
+ t.Fatalf("sitemap:rebuild: %v\n%s", err, out)
+ }
+ h, err := surf.Assemble(demo.App, demo.Plugins)
+ if err != nil {
+ t.Fatalf("surf.Assemble: %v", err)
+ }
+ do := func(method, path, body string, header map[string]string) *httptest.ResponseRecorder {
+ t.Helper()
+ req := httptest.NewRequest(method, path, strings.NewReader(body))
+ for k, v := range header {
+ req.Header.Set(k, v)
+ }
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ return rec
+ }
+ get := func(path string) string {
+ t.Helper()
+ rec := do(http.MethodGet, path, "", nil)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("GET %s = %d %s", path, rec.Code, rec.Body.String())
+ }
+ return rec.Body.String()
+ }
+
+ t.Run("sitemap index", func(t *testing.T) {
+ index := get("/sitemap.xml")
+ if !strings.Contains(index, "<sitemapindex") {
+ t.Fatalf("/sitemap.xml is not a sitemap index:\n%s", index)
+ }
+ if !strings.Contains(index, "<loc>"+demoSite+"/sitemap-posts-1.xml</loc>") {
+ t.Fatalf("/sitemap.xml does not list %s/sitemap-posts-1.xml:\n%s", demoSite, index)
+ }
+ posts := get("/_sitemap/files/sitemap-posts-1.xml")
+ if !strings.Contains(posts, "<urlset") || !strings.Contains(posts, "<loc>"+demoSite+"/blog/welcome-to-summercms</loc>") {
+ t.Fatalf("sitemap-posts-1.xml does not list the seeded post:\n%s", posts)
+ }
+ if strings.Contains(index, "sitemap-tags-") || strings.Contains(posts, "/blog/tag/") {
+ t.Fatalf("the sitemaps name tag addresses although golem15.journal.tag_pages is false")
+ }
+ })
+
+ t.Run("site answer", func(t *testing.T) {
+ var site struct {
+ SiteURL string `json:"site_url"`
+ Locales []string `json:"locales"`
+ HTMLSitemap struct {
+ Enabled bool `json:"enabled"`
+ Paths map[string]string `json:"paths"`
+ } `json:"html_sitemap"`
+ }
+ if err := json.Unmarshal([]byte(get("/_seo/api/v1/site")), &site); err != nil {
+ t.Fatal(err)
+ }
+ if site.SiteURL != demoSite || !slices.Contains(site.Locales, "en") || !slices.Contains(site.Locales, "pl") {
+ t.Fatalf("site answer %+v, want %s with en and pl", site, demoSite)
+ }
+ if !site.HTMLSitemap.Enabled || site.HTMLSitemap.Paths["en"] != "/sitemap" {
+ t.Fatalf("html_sitemap %+v, want enabled at /sitemap (vue-seo-layer's default path)", site.HTMLSitemap)
+ }
+ })
+
+ t.Run("post resolve", func(t *testing.T) {
+ var answer struct {
+ Action string `json:"action"`
+ SEO struct {
+ Canonical string `json:"canonical"`
+ } `json:"seo"`
+ JSONLD struct {
+ Graph []map[string]any `json:"@graph"`
+ } `json:"jsonld"`
+ Breadcrumbs []struct {
+ Name string `json:"name"`
+ Path string `json:"path"`
+ } `json:"breadcrumbs"`
+ }
+ if err := json.Unmarshal([]byte(get("/_seo/api/v1/resolve?url=/blog/welcome-to-summercms")), &answer); err != nil {
+ t.Fatal(err)
+ }
+ if answer.Action != "ok" || answer.SEO.Canonical != demoSite+"/blog/welcome-to-summercms" {
+ t.Fatalf("resolve = %s, canonical %q", answer.Action, answer.SEO.Canonical)
+ }
+ var types []string
+ for _, node := range answer.JSONLD.Graph {
+ if typ, ok := node["@type"].(string); ok {
+ types = append(types, typ)
+ }
+ }
+ for _, want := range []string{"WebPage", "BlogPosting", "BreadcrumbList", "WebSite"} {
+ if !slices.Contains(types, want) {
+ t.Fatalf("@graph types %v lack %s", types, want)
+ }
+ }
+ crumbs := answer.Breadcrumbs
+ if len(crumbs) < 3 || crumbs[0].Path != "/" || crumbs[len(crumbs)-1].Path != "/blog/welcome-to-summercms" ||
+ !strings.HasPrefix(crumbs[1].Path, "/blog/category/") {
+ t.Fatalf("breadcrumbs %+v, want home, the category and the post", crumbs)
+ }
+ })
+
+ t.Run("route report", func(t *testing.T) {
+ body := `{"routes": ["/", "/faq", "/contact"]}`
+ if rec := do(http.MethodPost, "/_seo/api/v1/registry/routes", body, map[string]string{"Content-Type": "application/json"}); rec.Code != http.StatusUnauthorized {
+ t.Fatalf("route report without a token = %d, want 401", rec.Code)
+ }
+ rec := do(http.MethodPost, "/_seo/api/v1/registry/routes", body, map[string]string{
+ "Content-Type": "application/json", "Authorization": "Bearer " + reportToken,
+ })
+ if rec.Code != http.StatusOK {
+ t.Fatalf("route report = %d %s", rec.Code, rec.Body.String())
+ }
+ var urls []string
+ if err := demo.DB.Raw(`SELECT url FROM golem15_seo_pages WHERE reported_at IS NOT NULL ORDER BY url`).Scan(&urls).Error; err != nil {
+ t.Fatal(err)
+ }
+ if !slices.Contains(urls, "/faq") || !slices.Contains(urls, "/contact") {
+ t.Fatalf("reported pages %v, want /faq and /contact", urls)
+ }
+ })
+}

View File

@@ -0,0 +1 @@
# no change beyond the pin bumps in 33-14

View File

@@ -0,0 +1,322 @@
diff --git a/app/components/SiteFooter.vue b/app/components/SiteFooter.vue
index 604a4b7..54e7626 100644
--- a/app/components/SiteFooter.vue
+++ b/app/components/SiteFooter.vue
@@ -1,7 +1,6 @@
-<!-- The site footer in summercms.io's: the tagline, the backend call to action and links. -->
+<!-- The site footer in summercms.io's: the tagline, the backend call to action, links and the HTML sitemap. -->
<script setup lang="ts">
const { t } = useI18n()
-const sitemapUrl = useRuntimeConfig().public.sitemapUrl as string
</script>
<template>
@@ -16,7 +15,8 @@ const sitemapUrl = useRuntimeConfig().public.sitemapUrl as string
<BackendCta />
<a href="https://summercms.io">{{ t('footer.website') }}</a>
<a href="https://summercms.io/docs">{{ t('footer.docs') }}</a>
- <a :href="sitemapUrl">{{ t('footer.sitemap') }}</a>
+ <!-- The HTML sitemap (vue-seo-layer), shown while the CMS has it on. -->
+ <SeoSitemapLink />
</div>
</div>
</footer>
diff --git a/app/pages/account/security.vue b/app/pages/account/security.vue
index 470d09f..d09f776 100644
--- a/app/pages/account/security.vue
+++ b/app/pages/account/security.vue
@@ -8,7 +8,9 @@
import { isTotpCode, svgDataUrl, type TotpSetup, type TwoFactorStatus } from '~/lib/twoFactor'
const { t } = useI18n()
-useSeoMeta({ title: () => t('security.title'), robots: 'noindex' })
+// A signed-in page: vue-seo-layer marks it noindex, nofollow from this meta.
+definePageMeta({ requiresAuth: true })
+useSeoMeta({ title: () => t('security.title') })
const { loggedIn } = useDemoAuth()
const twoFactor = useTwoFactor()
diff --git a/app/pages/call/[conversation].vue b/app/pages/call/[conversation].vue
index 32444e2..7db746e 100644
--- a/app/pages/call/[conversation].vue
+++ b/app/pages/call/[conversation].vue
@@ -12,7 +12,9 @@
<script setup lang="ts">
const { t } = useI18n()
const route = useRoute()
-useSeoMeta({ title: () => t('call.title'), description: () => t('call.lead'), robots: 'noindex' })
+// A signed-in page: vue-seo-layer marks it noindex, nofollow from this meta.
+definePageMeta({ requiresAuth: true })
+useSeoMeta({ title: () => t('call.title'), description: () => t('call.lead') })
const { loggedIn } = useDemoAuth()
const conversationId = computed(() => String(route.params.conversation ?? ''))
diff --git a/app/pages/connect.vue b/app/pages/connect.vue
index 4db725d..2c5f540 100644
--- a/app/pages/connect.vue
+++ b/app/pages/connect.vue
@@ -18,7 +18,9 @@ type ConsentRequest = {
}
const { t } = useI18n()
-useSeoMeta({ title: () => t('connect.title'), robots: 'noindex' })
+// An auth page: vue-seo-layer marks it noindex, nofollow from this meta.
+definePageMeta({ seo: { robots: 'noindex, nofollow' } })
+useSeoMeta({ title: () => t('connect.title') })
const route = useRoute()
const { token, loggedIn } = useDemoAuth()
diff --git a/app/pages/forum/t/[id].vue b/app/pages/forum/t/[id].vue
index e574901..8c5a66f 100644
--- a/app/pages/forum/t/[id].vue
+++ b/app/pages/forum/t/[id].vue
@@ -13,7 +13,9 @@ import { DEMO_API } from '~/lib/api/demo'
const { t } = useI18n()
const route = useRoute()
const threadId = computed(() => String(route.params.id ?? ''))
-useSeoMeta({ title: () => t('forum.title'), description: () => t('forum.lead'), robots: 'noindex' })
+// A signed-in page: vue-seo-layer marks it noindex, nofollow from this meta.
+definePageMeta({ requiresAuth: true })
+useSeoMeta({ title: () => t('forum.title'), description: () => t('forum.lead') })
const { loggedIn } = useDemoAuth()
const registerLink = computed(() => ({ path: '/register', query: { next: route.path } }))
diff --git a/app/pages/friends.vue b/app/pages/friends.vue
index 8f5bdef..11d9bd9 100644
--- a/app/pages/friends.vue
+++ b/app/pages/friends.vue
@@ -8,7 +8,9 @@
-->
<script setup lang="ts">
const { t } = useI18n()
-useSeoMeta({ title: () => t('friends.title'), description: () => t('friends.lead'), robots: 'noindex' })
+// A signed-in page: vue-seo-layer marks it noindex, nofollow from this meta.
+definePageMeta({ requiresAuth: true })
+useSeoMeta({ title: () => t('friends.title'), description: () => t('friends.lead') })
const { loggedIn } = useDemoAuth()
const registerLink = { path: '/register', query: { next: '/friends' } }
diff --git a/app/pages/link-admin.vue b/app/pages/link-admin.vue
index 08bfe42..e4205fb 100644
--- a/app/pages/link-admin.vue
+++ b/app/pages/link-admin.vue
@@ -17,7 +17,9 @@ type LinkDone = { data: { backend_user: { login: string; name: string } } }
type SignedIn = { user: { email?: string } }
const { t, locale } = useI18n()
-useSeoMeta({ title: () => t('linkAdmin.title'), robots: 'noindex' })
+// An auth page: vue-seo-layer marks it noindex, nofollow from this meta.
+definePageMeta({ seo: { robots: 'noindex, nofollow' } })
+useSeoMeta({ title: () => t('linkAdmin.title') })
const { token, loggedIn, logout } = useDemoAuth()
const code = ref('')
diff --git a/app/pages/login.vue b/app/pages/login.vue
index 9bc9442..fba22a4 100644
--- a/app/pages/login.vue
+++ b/app/pages/login.vue
@@ -2,7 +2,9 @@
import { challengeExpired, normalizeRecoveryCode, type TwoFactorChallenge } from '~/lib/twoFactor'
const { t } = useI18n()
-useSeoMeta({ title: () => t('auth.login.title'), robots: 'noindex' })
+// An auth page: vue-seo-layer marks it noindex, nofollow from this meta.
+definePageMeta({ seo: { robots: 'noindex, nofollow' } })
+useSeoMeta({ title: () => t('auth.login.title') })
const route = useRoute()
const { loggedIn, login, verifyChallenge, recoverChallenge, logout } = useDemoAuth()
const email = ref('')
diff --git a/app/pages/presentations/[slug]/edit.vue b/app/pages/presentations/[slug]/edit.vue
index 1687dd3..d16a504 100644
--- a/app/pages/presentations/[slug]/edit.vue
+++ b/app/pages/presentations/[slug]/edit.vue
@@ -5,7 +5,7 @@
-->
<script setup lang="ts">
const { t } = useI18n()
-useSeoMeta({ title: () => t('deckForm.editTitle'), robots: 'noindex' })
+useSeoMeta({ title: () => t('deckForm.editTitle') })
useDeckSession()
const route = useRoute()
const slug = computed(() => String(route.params.slug ?? ''))
diff --git a/app/pages/presentations/index.vue b/app/pages/presentations/index.vue
index ca69e6f..94c353a 100644
--- a/app/pages/presentations/index.vue
+++ b/app/pages/presentations/index.vue
@@ -14,7 +14,7 @@ import { presentationApi, type MineCard } from '~/lib/api/presentations'
import { remoteLinkClick } from '~/lib/pilot'
const { t } = useI18n()
-useSeoMeta({ title: () => t('mine.title'), robots: 'noindex' })
+useSeoMeta({ title: () => t('mine.title') })
const route = useRoute()
const { loggedIn } = useDeckSession()
diff --git a/app/pages/presentations/new.vue b/app/pages/presentations/new.vue
index 46728c6..8971da2 100644
--- a/app/pages/presentations/new.vue
+++ b/app/pages/presentations/new.vue
@@ -6,7 +6,7 @@
-->
<script setup lang="ts">
const { t } = useI18n()
-useSeoMeta({ title: () => t('deckForm.newTitle'), robots: 'noindex' })
+useSeoMeta({ title: () => t('deckForm.newTitle') })
useDeckSession()
</script>
diff --git a/app/pages/quizzes/[slug].vue b/app/pages/quizzes/[slug].vue
index 4bbf1e9..0170ae2 100644
--- a/app/pages/quizzes/[slug].vue
+++ b/app/pages/quizzes/[slug].vue
@@ -19,7 +19,10 @@ const slug = computed(() => String(route.params.slug ?? ''))
const { loggedIn } = useDeckSession()
const quiz = ref<Quiz | null>(null)
-useSeoMeta({ title: () => quiz.value?.title ?? t('quiz.listTitle'), robots: 'noindex' })
+// Taking a quiz needs an account, so the page stays out of the index
+// (vue-seo-layer applies this meta); the quiz list is public.
+definePageMeta({ seo: { robots: 'noindex' } })
+useSeoMeta({ title: () => quiz.value?.title ?? t('quiz.listTitle') })
const loadError = ref('')
const history = ref<Attempt[]>([])
diff --git a/app/pages/register.vue b/app/pages/register.vue
index 3a688c4..fc5836c 100644
--- a/app/pages/register.vue
+++ b/app/pages/register.vue
@@ -3,7 +3,9 @@
// POST /_user/api/v1/register behind Cloudflare Turnstile (D-07). No field is
// pre-filled and there is no e-mail verification step.
const { t } = useI18n()
-useSeoMeta({ title: () => t('auth.register.title'), robots: 'noindex' })
+// An auth page: vue-seo-layer marks it noindex, nofollow from this meta.
+definePageMeta({ seo: { robots: 'noindex, nofollow' } })
+useSeoMeta({ title: () => t('auth.register.title') })
const { loggedIn, register } = useDemoAuth()
const route = useRoute()
// A same-site path to return to after signing up (the quizzes' "register to
diff --git a/app/pages/roadmap.vue b/app/pages/roadmap.vue
index 201b0f7..5b83e96 100644
--- a/app/pages/roadmap.vue
+++ b/app/pages/roadmap.vue
@@ -10,7 +10,10 @@
import { DEMO_API, fetchRoadmap, roadmapPath } from '~/lib/api/demo'
const { t, locale } = useI18n()
-useSeoMeta({ title: () => t('roadmap.title'), robots: 'noindex' })
+// Only a redirect to the roadmap deck (/p/..., declared noindex), so the
+// page itself stays out of the index (vue-seo-layer applies this meta).
+definePageMeta({ seo: { robots: 'noindex' } })
+useSeoMeta({ title: () => t('roadmap.title') })
const base = import.meta.server ? useRuntimeConfig().demo.apiBase : DEMO_API
const { data: deck, error } = await useAsyncData(`roadmap-${locale.value}`, () => fetchRoadmap(base, locale.value))
diff --git a/app/pages/subscriptions.vue b/app/pages/subscriptions.vue
index c2c3046..9a49ea2 100644
--- a/app/pages/subscriptions.vue
+++ b/app/pages/subscriptions.vue
@@ -1,5 +1,7 @@
<script setup lang="ts">
const { t } = useI18n()
+// A signed-in page: vue-seo-layer marks it noindex, nofollow from this meta.
+definePageMeta({ requiresAuth: true })
useSeoMeta({ title: () => t('subscriptions.title') })
const route = useRoute()
const { loggedIn } = useDemoAuth()
diff --git a/i18n/locales/en.json b/i18n/locales/en.json
index 126c4ee..56f81b3 100644
--- a/i18n/locales/en.json
+++ b/i18n/locales/en.json
@@ -37,8 +37,7 @@
"footer": {
"tagline": "{name} demo · one Go binary, every shared Golem15 plugin.",
"website": "summercms.io",
- "docs": "Docs",
- "sitemap": "Sitemap"
+ "docs": "Docs"
},
"cta": {
"link": "Want to see the backend? Contact Golem15",
diff --git a/i18n/locales/pl.json b/i18n/locales/pl.json
index ca8e9d4..c8183c6 100644
--- a/i18n/locales/pl.json
+++ b/i18n/locales/pl.json
@@ -37,8 +37,7 @@
"footer": {
"tagline": "Demo {name} · jeden plik binarny Go, wszystkie wspólne wtyczki Golem15.",
"website": "summercms.io",
- "docs": "Dokumentacja",
- "sitemap": "Mapa strony"
+ "docs": "Dokumentacja"
},
"cta": {
"link": "Chcesz zobaczyć panel administracyjny? Napisz do Golem15",
diff --git a/nuxt.config.ts b/nuxt.config.ts
index df8bb2d..6cb0c97 100644
--- a/nuxt.config.ts
+++ b/nuxt.config.ts
@@ -57,7 +57,9 @@ export default defineNuxtConfig({
devtools: { enabled: false },
// The journal and payment gateway layers declare their Nuxt modules
- // (@nuxt/fonts, @nuxtjs/i18n, @nuxtjs/seo); this app lists none.
+ // (@nuxt/fonts, @nuxtjs/i18n); this app lists none. The journal, menu and
+ // FAQ layers extend vue-seo-layer, which sets every page's head from the
+ // SEO plugin (golem15.seo) and serves robots.txt and the sitemaps from Go.
extends: ['vue-journal-layer', 'vue-menu-layer', 'vue-faq-layer', 'vue-paymentgateway-layer', 'vue-chat-layer', 'vue-chatvideo-layer', 'vue-forum-layer', 'vue-userfriends-layer'],
journal: {
@@ -91,11 +93,15 @@ export default defineNuxtConfig({
},
},
- site: {
- url: 'http://127.0.0.1:3020',
- name: 'SummerCMS Demo',
- description: 'Every shared Golem15 plugin on one SummerCMS binary.',
- defaultLocale: 'en',
+ // vue-seo-layer (sm-demo-app DEPLOY.md, "SEO"). The deck viewers render in
+ // the browser only (routeRules below), so they are declared noindex: the
+ // layer sends them X-Robots-Tag: noindex, nofollow and its build guard
+ // (D-70) accepts them; any other browser-only route fails the build. Auth
+ // and signed-in pages mark themselves in their page meta (seo.robots,
+ // requiresAuth). The HTML sitemap pages keep the layer's paths
+ // (/sitemap, /mapa-strony), which the SEO plugin's site answer names.
+ seo: {
+ noindexRoutes: ['/p/**', '/presentations/**'],
},
runtimeConfig: {
@@ -109,9 +115,16 @@ export default defineNuxtConfig({
userfriends: { apiBase: `${api}/_userfriends/api/v1` },
// The demo's own API (golem15.demo) for SSR fetches. NUXT_DEMO_API_BASE.
demo: { apiBase: `${api}/_demo/api/v1` },
+ // The SEO plugin's API for the resolve, the site answer, the 404 report
+ // and the route report (NUXT_SEO_API_BASE); robots.txt and the sitemap
+ // files come from the same Go origin (NUXT_SEO_BACKEND_BASE, empty: this
+ // base without /_seo/api/v1). The tokens are NUXT_SEO_NOTFOUND_TOKEN and
+ // NUXT_SEO_ROUTE_REPORT_TOKEN at run time, never committed.
+ seo: { apiBase: `${api}/_seo/api/v1` },
public: {
- // The Go sitemap plugin's document (the Nuxt one lists only the journal).
- sitemapUrl: `${api}/sitemap.xml`,
+ // Client-side navigations resolve through the Nuxt server
+ // (GET /api/__seo__/resolve, its cache), so /_seo needs no public route.
+ seo: { apiBase: '/api/__seo__' },
// Cloudflare Turnstile site key for register and login (D-07); public by
// design. NUXT_PUBLIC_TURNSTILE_SITE_KEY at runtime.
turnstileSiteKey: '',
@@ -146,12 +159,6 @@ export default defineNuxtConfig({
},
},
- // The admin path is not listed: no public page links to the admin (D-04), and
- // naming it here would print it into every page's payload and robots.txt.
- // The admin answers with X-Robots-Tag: noindex itself.
- robots: { disallow: goPaths },
- linkChecker: { enabled: false },
-
// The deck pages are bm's presentation views (D-14): they touch window,
// document and storage throughout, so they render in the browser only.
routeRules: {

View File

@@ -0,0 +1,57 @@
diff --git a/changelog/nuxt.config.ts b/changelog/nuxt.config.ts
index 67cb8c9..6bb556f 100644
--- a/changelog/nuxt.config.ts
+++ b/changelog/nuxt.config.ts
@@ -32,12 +32,21 @@ export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
devtools: { enabled: false },
- // '..' first (header, footer, tokens, fonts, site config, landing strings),
- // then the Journal layer (pages, API client, sanitizer, SEO). The layer
- // declares @nuxt/fonts, @nuxtjs/i18n and @nuxtjs/seo, which the landing
- // lists at the same exact versions, so this config lists no modules.
+ // '..' first (header, footer, tokens, fonts, site config, landing strings,
+ // and @nuxtjs/seo for the canonical, robots and sitemap of the landing's
+ // site config), then the Journal layer (pages, API client, sanitizer). The
+ // layer declares @nuxt/fonts and @nuxtjs/i18n, which the landing lists at
+ // the same exact versions, so this config lists no modules.
extends: ['..', 'vue-journal-layer'],
+ // The Journal layer extends vue-seo-layer, the frontend of the SEO plugin.
+ // summercms.io's binary mounts no SEO plugin, so the layer makes no CMS
+ // call (no resolve, no root files, no 404 or route report): the pages keep
+ // the Journal layer's own head and @nuxtjs/seo's canonical. Without the CMS
+ // there is no HTML sitemap, so the layer registers no sitemap pages. The
+ // build guard still runs; every page here renders on the server.
+ seo: { cms: false, htmlSitemap: false },
+
// Layer CSS loads first (tokens, base), this file last. In this project's own
// config `~` is changelog/app.
css: ['~/assets/css/changelog.css'],
diff --git a/changelog/testing/expect.txt b/changelog/testing/expect.txt
index 4af772d..7fb7df2 100644
--- a/changelog/testing/expect.txt
+++ b/changelog/testing/expect.txt
@@ -55,6 +55,22 @@
# SEO
200 /changelog/summercms-0-5-0 contains=<link rel="canonical" href="https://summercms.io/changelog/summercms-0-5-0"
200 /changelog notcontains=content="noindex
+# vue-seo-layer is off (seo.cms false, the binary mounts no SEO plugin): the
+# titles come from the Journal layer's fallback meta with the landing's
+# title template, @nuxtjs/seo adds one canonical and one graph, no CMS
+# breadcrumbs, no HTML sitemap pages and not one resolve call.
+200 /changelog/summercms-0-5-0 contains=<title>SummerCMS 0.5.0 | SummerCMS</title>
+200 /changelog/summercms-0-5-0 count=rel="canonical":1
+200 /changelog/summercms-0-5-0 count=application/ld+json:1
+200 /changelog/summercms-0-5-0 count=<h1:1
+200 /changelog/summercms-0-5-0 notcontains=seo-breadcrumbs
+200 /changelog contains=<title>Changelog | SummerCMS</title>
+200 /changelog count=rel="canonical":1
+200 /changelog count=application/ld+json:1
+200 /changelog/plugins count=rel="canonical":1
+404 /sitemap
+404 /mapa-strony
+resolves * 0
# Asset prefixes
200 /changelog contains="/changelog/_nuxt/