diff --git a/.planning/phases/33-seo-output-audit-and-content/33-12-local-wiring.md b/.planning/phases/33-seo-output-audit-and-content/33-12-local-wiring.md new file mode 100644 index 00000000..450c7b27 --- /dev/null +++ b/.planning/phases/33-seo-output-audit-and-content/33-12-local-wiring.md @@ -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 < $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 < $S/demo-run.vars < 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. diff --git a/.planning/phases/33-seo-output-audit-and-content/33-12-sm-demo-app.patch b/.planning/phases/33-seo-output-audit-and-content/33-12-sm-demo-app.patch new file mode 100644 index 00000000..2bc47243 --- /dev/null +++ b/.planning/phases/33-seo-output-audit-and-content/33-12-sm-demo-app.patch @@ -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--.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:.}' "$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: .}' "$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--.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 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--.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 '", 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 '