From c7a485177e2106714e6b765be4c7c44126b95ec0 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 4 Nov 2026 20:25:55 +0100 Subject: [PATCH] docs(33-14): demo locale patches, release-note drafts and open items for the release checkpoint - 33-14-vue-demo-app-locale.patch and 33-14-sm-demo-app-locale.patch move the demo to prefix_except_default on top of the 33-12 demo patches - release-note drafts for the framework, the three plugins and four layers - the read-only RISK-MAX query for production databases - deferred items and the broken-windows ledger after the six fixes --- .planning/WINDOWS.md | 12 +- .../33-14-notes-sm-journal-plugin-v0.4.0.md | 23 + .../33-14-notes-sm-seo-plugin-v0.2.0.md | 27 + .../33-14-notes-sm-sitemap-plugin-v0.2.0.md | 26 + .../33-14-notes-summercms-v0.6.1.md | 25 + .../33-14-notes-vue-faq-layer-v0.5.0.md | 16 + .../33-14-notes-vue-journal-layer-v0.8.0.md | 24 + .../33-14-notes-vue-menu-layer-v0.5.0.md | 11 + .../33-14-notes-vue-seo-layer-v0.2.0.md | 24 + .../33-14-risk-max.sql | 35 + .../33-14-sm-demo-app-locale.patch | 371 +++++++++ .../33-14-vue-demo-app-locale.patch | 757 ++++++++++++++++++ .../deferred-items.md | 29 +- 13 files changed, 1364 insertions(+), 16 deletions(-) create mode 100644 .planning/phases/33-seo-output-audit-and-content/33-14-notes-sm-journal-plugin-v0.4.0.md create mode 100644 .planning/phases/33-seo-output-audit-and-content/33-14-notes-sm-seo-plugin-v0.2.0.md create mode 100644 .planning/phases/33-seo-output-audit-and-content/33-14-notes-sm-sitemap-plugin-v0.2.0.md create mode 100644 .planning/phases/33-seo-output-audit-and-content/33-14-notes-summercms-v0.6.1.md create mode 100644 .planning/phases/33-seo-output-audit-and-content/33-14-notes-vue-faq-layer-v0.5.0.md create mode 100644 .planning/phases/33-seo-output-audit-and-content/33-14-notes-vue-journal-layer-v0.8.0.md create mode 100644 .planning/phases/33-seo-output-audit-and-content/33-14-notes-vue-menu-layer-v0.5.0.md create mode 100644 .planning/phases/33-seo-output-audit-and-content/33-14-notes-vue-seo-layer-v0.2.0.md create mode 100644 .planning/phases/33-seo-output-audit-and-content/33-14-risk-max.sql create mode 100644 .planning/phases/33-seo-output-audit-and-content/33-14-sm-demo-app-locale.patch create mode 100644 .planning/phases/33-seo-output-audit-and-content/33-14-vue-demo-app-locale.patch diff --git a/.planning/WINDOWS.md b/.planning/WINDOWS.md index 2110bbe1..840bb436 100644 --- a/.planning/WINDOWS.md +++ b/.planning/WINDOWS.md @@ -1,10 +1,10 @@ --- schema_version: 1 -open_count: 27 +open_count: 26 waived_count: 0 -fixed_count: 8 +fixed_count: 9 total_count: 35 -last_updated: 2026-11-04T17:10:14.144Z +last_updated: 2026-11-04T19:19:01.124Z --- # Broken Windows Ledger @@ -49,7 +49,7 @@ last_updated: 2026-11-04T17:10:14.144Z | 32 | 33 | deviation | controllers/issues/config_filter.yaml | | sm-seo-plugin: the Site audit status filter cannot default to Open (cabana list filters have no default value), so the list shows ignored issues too, badged Ignored; GET /_seo/api/v1/issues and seo_list_issues default to status open; set the default once cabana filters accept one | open | | 2026-11-04T15:03:28.372Z | | | 33 | 33 | deviation | controllers/issues/_issue_details.htm | | sm-seo-plugin: P7 shows Open the page and the Rich Results Test as labelled addresses, not links, because the partial allowlist keeps only same-origin hrefs; make them links once partials may carry an external link (new tab) | open | | 2026-11-04T15:03:28.619Z | | | 34 | 33 | deviation | vue-demo-app/nuxt.config.ts | | 33-12: the demo serves Polish by cookie at one address (no_prefix), but the CMS names /pl/... alternates and sitemap addresses, which answer 404; i18n strategy decision before 33-14 applies the demo patches | open | | 2026-11-04T17:10:13.946Z | | -| 35 | 33 | todo | sm-sitemap-plugin/classes/rebuild.go | | 33-12: additional addresses are not de-duplicated against content records (migrated /, /blog, /faq listed twice on rome-like data); the demo DEPLOY step works around it | open | | 2026-11-04T17:10:14.144Z | | +| 35 | 33 | todo | sm-sitemap-plugin/classes/rebuild.go | | 33-12: additional addresses are not de-duplicated against content records (migrated /, /blog, /faq listed twice on rome-like data); the demo DEPLOY step works around it | fixed | | 2026-11-04T17:10:14.144Z | 2026-11-04T19:19:01.124Z | ````json [ @@ -502,10 +502,10 @@ last_updated: 2026-11-04T17:10:14.144Z "file": "sm-sitemap-plugin/classes/rebuild.go", "line": null, "description": "33-12: additional addresses are not de-duplicated against content records (migrated /, /blog, /faq listed twice on rome-like data); the demo DEPLOY step works around it", - "status": "open", + "status": "fixed", "reason": "", "recorded_at": "2026-11-04T17:10:14.144Z", - "resolved_at": null, + "resolved_at": "2026-11-04T19:19:01.124Z", "milestone": "v1.0" } ] diff --git a/.planning/phases/33-seo-output-audit-and-content/33-14-notes-sm-journal-plugin-v0.4.0.md b/.planning/phases/33-seo-output-audit-and-content/33-14-notes-sm-journal-plugin-v0.4.0.md new file mode 100644 index 00000000..f911a36a --- /dev/null +++ b/.planning/phases/33-seo-output-audit-and-content/33-14-notes-sm-journal-plugin-v0.4.0.md @@ -0,0 +1,23 @@ +Excerpt: One H1 per post page, a heading outline and link attributes in the editor, WebP and resized variants of featured images, opt-in scored related posts, and richer content facts for SEO. + +## Highlights + +- **One H1 per page.** With the new setting on (the default), H1 headings in post and category content are saved as H2, so the page title stays the only H1. `journal:fix-headings` converts stored content. +- **Featured images in WebP.** After an upload, a job writes resized variants (480, 800 and 1200 px by default) and a WebP copy of each; the API lists them and the journal layer serves them through ``. +- **A better editor.** Heading levels follow the setting, an outline flags skipped, empty and link-only headings, links get `rel` options and a warning for generic link texts, and uploaded image names are slugged. + +## Added + +- Settings › Journal, tab Headings and links: Convert H1 to H2 (`content_h1_to_h2`) and the generic link texts; the settings API and MCP tools for settings, headings and image alt texts. +- Content facts for other plugins: publication date, categories as parents, authors, images (with alt and decorative), YouTube embeds and the heading outline; a `journal-author` content type. +- `journal:image-variants` to backfill variants of existing featured images, and the `golem15.journal.image_widths` config key. +- Opt-in scored related posts (`golem15.journal.related_mode: scored`, shared tags and categories) with the previous and next post in the category. + +## Changed + +- **Requires SummerCMS v0.6.1.** +- **Upgrade:** run the migrations, then `journal:fix-headings` (dry run first) to convert H1s already stored, and `journal:image-variants` once to write the variants of existing featured images. For the faster WebP encoder install libwebp on the server; the binary is now dynamically linked against glibc (see SummerCMS v0.6.1). + +## Fixed + +- `journal:image-variants` runs from the application's CLI: without a running server it opens the database and the uploads bucket itself instead of stopping with "database is not configured". diff --git a/.planning/phases/33-seo-output-audit-and-content/33-14-notes-sm-seo-plugin-v0.2.0.md b/.planning/phases/33-seo-output-audit-and-content/33-14-notes-sm-seo-plugin-v0.2.0.md new file mode 100644 index 00000000..f9960f1d --- /dev/null +++ b/.planning/phases/33-seo-output-audit-and-content/33-14-notes-sm-seo-plugin-v0.2.0.md @@ -0,0 +1,27 @@ +Excerpt: Structured data, breadcrumbs, hreflang and pagination in every resolve answer, a page registry, a site audit crawler with fix routes, auto-links and an internal links widget. + +## Highlights + +- **Complete SEO output from the CMS.** The resolve answer now carries one JSON-LD `@graph` per page (built per content kind, with the publisher or local business), breadcrumbs with a primary category, hreflang alternates, ``, the pagination form and the page's outbound link rules. +- **Site audit.** A crawler reads the site like a search engine (statuses, sitemaps, canonicals, titles, headings, JSON-LD, hreflang, links, images, orphan pages, content that appears only after JavaScript) and stores issues that name the tool that fixes them. +- **Auto-links.** Phrases link to their pages in post bodies, fed from focus keyphrases, with an internal links widget in the SEO tab. + +## Added + +- A page registry (SEO › Pages) for the frontend's own pages, filled by the frontend's route report, with addresses and names per locale and a kind (page, offer, service, landing). +- `GET /_seo/api/v1/site` for the frontend layer, `llms.txt`, and the sitemap index in robots.txt. +- Publisher, local business and home crumb settings; per-record schema type, Service fields and custom JSON-LD with validation and a graph preview in the SEO tab. +- Pagination in the query or path form with title and description suffixes. +- Site audit settings, a nightly crawl, Site audit and Crawls screens and their MCP tools. With `golem15.seo.crawl_chrome_path` set, a rendered comparison through headless Chrome (chromedp). +- A sitemap filter for sm-sitemap-plugin that leaves noindex, canonicalised and redirected addresses out. +- Links settings (outbound rules, new tab) and site cache purges after SEO writes. + +## Changed + +- **Requires SummerCMS v0.6.1 and sm-sitemap-plugin v0.2.0** (its filter contract). +- **Upgrade:** set `golem15.seo.site_url` to the public origin. Give the frontend a route report token (`golem15.seo.route_report_token`, else the 404 token is used). After the first frontend start, check SEO › Pages and give the reported pages their titles and their addresses in other locales. + +## Fixed + +- Registry pages store their created and updated times in UTC, so a page without a content date no longer gets a sitemap lastmod in the future on a server east of UTC. +- A content type's plain label is named untranslated in the address-owned refusal. diff --git a/.planning/phases/33-seo-output-audit-and-content/33-14-notes-sm-sitemap-plugin-v0.2.0.md b/.planning/phases/33-seo-output-audit-and-content/33-14-notes-sm-sitemap-plugin-v0.2.0.md new file mode 100644 index 00000000..0c8aaf29 --- /dev/null +++ b/.planning/phases/33-seo-output-audit-and-content/33-14-notes-sm-sitemap-plugin-v0.2.0.md @@ -0,0 +1,26 @@ +Excerpt: The sitemap is now an index of per-type files with real lastmod, hreflang, image and News sitemaps, IndexNow and an HTML sitemap. Breaking: the WinterCMS-format sitemap, its definitions screen and the XSL are gone. + +## Highlights + +- **A sitemap index of per-type files.** `/sitemap.xml` (same address) lists `/sitemap--.xml` files built from every content type's published records and the additional addresses. Addresses keep their file between rebuilds; only a change of the file size renumbers. +- **Real lastmod.** A record's lastmod moves only when a save changes something that matters (configurable ignore list), and rebuilds are debounced after record events and run nightly. +- **Image and News sitemaps, IndexNow and an HTML sitemap.** + +## Added + +- hreflang alternates for records with more than one translated locale, with `x-default`. +- Image sitemap files from each page's images, and a Google News sitemap (`sitemap-news.xml`) for articles published in the last 48 hours, limited to chosen types or categories. +- IndexNow: a key, its key file and pings of added, changed and removed addresses in production. +- An HTML sitemap API (groups, category trees, paging) for the site's sitemap page. +- One settings screen (Settings › Sitemaps) with the files table, the addresses left out by reason, the content types (include, file name, headings) and the additional addresses; a management API and MCP tools. +- A filter contract (`contract.Filter`) that lets another plugin leave addresses out (noindex, canonical elsewhere, redirect) and group them. + +## Changed + +- **Breaking.** The WinterCMS byte-format sitemap, priority tiers, the definitions screen, the old settings tile and `/sitemap.xsl` are removed. `` and `` are no longer written. The config keys `golem15.sitemap.cache_seconds` and `golem15.sitemap.priority_tiers` and the events `BeforeAddItem` and `AddItem` are gone. +- **Upgrade:** run the migrations: the definition's url items move to the additional addresses, `frontend_url` to the site address, `frontend_default_locale` to the default locale; the old table stays, unread. Serve the root file names from `/_sitemap/files/` (vue-seo-layer v0.2.0 does it; with nginx, a proxy rule) and remove any nginx `/sitemap*` location. Run `sitemap:rebuild` once after the release. Remove additional addresses that the site serves noindex. +- **Requires SummerCMS v0.6.1.** + +## Fixed + +- An additional address that a content record already lists is written once (it is counted as "Already listed by a content type" on the settings screen). diff --git a/.planning/phases/33-seo-output-audit-and-content/33-14-notes-summercms-v0.6.1.md b/.planning/phases/33-seo-output-audit-and-content/33-14-notes-summercms-v0.6.1.md new file mode 100644 index 00000000..147d4275 --- /dev/null +++ b/.planning/phases/33-seo-output-audit-and-content/33-14-notes-summercms-v0.6.1.md @@ -0,0 +1,25 @@ +Excerpt: Content descriptors carry dates, parents, authors, media and headings; plugin field types get per-request options; attachments get WebP variants; validation now checks string, url and min/max length the Laravel way, with Polish messages. + +## Highlights + +- **Richer content descriptors.** A content type can now tell other plugins what a record is and what it holds: its kind (article, collection, profile, page), the MCP tools that fix it, its first publication date, its parents, its authors, its top heading level, and per locale its images, videos and heading outline. Sitemaps, structured data and site audits read these facts without knowing the plugin behind them. +- **WebP variants of thumbnails.** `attach/webp` writes a WebP copy next to any attachment thumbnail, once, and `attach.VariantKey` names it. +- **Validation that matches Laravel.** `lagoon.Validate` accepts the `string` and `url` rules and checks `min`/`max` as a length on every field that is not numeric. + +## Added + +- `pact.ContentDescriptor.Kind` and `Tools`, with the `ContentKind*` and `ContentTool*` constants. +- `pact.ContentRecord.PublishedAt`, `Parents`, `Authors` and `TopHeading`; `pact.ContentLocale.Images`, `Videos` and `Headings`; the value types `ContentRef`, `ContentPerson`, `ContentImage`, `ContentVideo` and `ContentHeading`. All are optional: a descriptor that sets none behaves as before. +- `pact.FormFieldType.RequestOptions`: a plugin field type can add options that change per request (for example values read from the database). They join the field's `fields.yaml` options, which win on the same key; an error keeps the `fields.yaml` options. +- `attach.VariantKey(thumbKey, format)` and the `lagoon/attach/webp` package (`Encode`, `Variant`). Only binaries that import the package link the encoder. +- Polish messages for the `string` rule and numeric wording for the Polish `min` and `max` messages. + +## Changed + +- `lagoon.Validate` checks `min` and `max` as a length on every field that is not `integer` or `numeric`: characters for a string, items for a list. **A stored value longer than its rule's `max` now fails its next admin save until it is shortened.** Before upgrading, check the long text columns your plugins validate with a `max` (for example a description with `max:5000` on a TEXT column). +- A `min` or `max` bound that is not a whole number is now an error instead of being ignored. +- **Binaries are no longer fully static when they link `attach/webp`.** The WebP encoder loads the system libwebp through purego, so a `CGO_ENABLED=0` build is dynamically linked against glibc (`ld-linux-x86-64.so.2`, `libc.so.6`, `libdl.so.2`, `libpthread.so.0`). It runs on Debian and Ubuntu servers, not in `scratch`, distroless-static or Alpine images. With libwebp installed the fast encoder is used; without it, a slower built-in one. Build with `-tags nodynamic` for a static binary that always uses the built-in encoder. + +## Fixed + +- Settings forms whose rules use `string` or `url` (for example the Journal settings) can be saved from the admin again; they failed with "unrecognized validation rule". diff --git a/.planning/phases/33-seo-output-audit-and-content/33-14-notes-vue-faq-layer-v0.5.0.md b/.planning/phases/33-seo-output-audit-and-content/33-14-notes-vue-faq-layer-v0.5.0.md new file mode 100644 index 00000000..44e90b33 --- /dev/null +++ b/.planning/phases/33-seo-output-audit-and-content/33-14-notes-vue-faq-layer-v0.5.0.md @@ -0,0 +1,16 @@ +Excerpt: FAQ blocks add a FAQPage node to the page's one JSON-LD graph and can render their title as styled text for widgets; the layer now extends vue-seo-layer. + +## Highlights + +- **FAQPage structured data.** `FaqBlock` adds a `FAQPage` node with the questions and plain-text answers to the page's single JSON-LD graph (prop `schema`, default on). +- **Widget headings.** `headingLevel` 0 renders the FAQ name as a styled `

` that still labels the section, for sidebars and footers. + +## Added + +- `faqSchema()` and `faqAnswerText()` helpers. + +## Changed + +- **Breaking:** the layer extends vue-seo-layer, and the copied site cache purge route, library and plugin are removed (vue-seo-layer provides them with the same environment variables). +- **Upgrade:** set `NUXT_SEO_API_BASE`, or `seo: { cms: false }` for a site without the SEO plugin; an app without pages of its own sets `seo: { htmlSitemap: false }`. When a page shows several FAQs, set `schema` false on all but one. +- Requires vue-seo-layer v0.2.0. diff --git a/.planning/phases/33-seo-output-audit-and-content/33-14-notes-vue-journal-layer-v0.8.0.md b/.planning/phases/33-seo-output-audit-and-content/33-14-notes-vue-journal-layer-v0.8.0.md new file mode 100644 index 00000000..0ca48792 --- /dev/null +++ b/.planning/phases/33-seo-output-audit-and-content/33-14-notes-vue-journal-layer-v0.8.0.md @@ -0,0 +1,24 @@ +Excerpt: The journal layer now runs on vue-seo-layer: one H1 per post page, breadcrumbs, CMS pagination, ItemList nodes, WebP srcsets and category neighbours. Breaking: @nuxtjs/seo is gone and post cards have one link. + +## Highlights + +- **Built on the SEO layer.** Every journal page takes its head, hreflang and JSON-LD from the SEO plugin; listings add an `ItemList` node, post pages a `BlogPosting`, all in one graph. +- **One H1 in both Journal H1 modes.** The post title is the H1 while body H1s become H2s, and a `p` when the body keeps its own (`journal.postTitleTag` forces either). +- **Faster images.** Featured images are served through `` with WebP and srcset from the Journal's variants. + +## Added + +- Breadcrumbs at the top of journal pages (`journal.breadcrumbs: false` when the layout renders them), CMS pagination links, a category's pillar content on its first page. +- Previous and next posts in the category when the Journal's scored mode answers them. +- Auto-links, outbound link rules and image attributes applied to the post body. + +## Changed + +- **Breaking: `@nuxtjs/seo` is removed.** Remove `site: { url, name }` and the `ogImage`, `seo.canonicalQueryWhitelist`, `sitemap`, `robots` and `schemaOrg` keys; set `NUXT_SEO_API_BASE`, or `seo: { cms: false }` for a site without the SEO plugin. The journal sitemap source is gone; the sitemap plugin builds the sitemaps. +- **Breaking: post cards have one link.** The read-more link and the image link are removed; the whole card is clickable. The post title may be a `p.journal-post__title`, related post cards are `h3`. Adjust app CSS that targets the old elements. +- The shared site cache purge route moved to vue-seo-layer with the same path and variables. +- Requires vue-seo-layer v0.2.0. + +## Fixed + +- No more `Duplicated imports "JournalImageVariant"` warning on every build of an app with the layer. diff --git a/.planning/phases/33-seo-output-audit-and-content/33-14-notes-vue-menu-layer-v0.5.0.md b/.planning/phases/33-seo-output-audit-and-content/33-14-notes-vue-menu-layer-v0.5.0.md new file mode 100644 index 00000000..fb651050 --- /dev/null +++ b/.planning/phases/33-seo-output-audit-and-content/33-14-notes-vue-menu-layer-v0.5.0.md @@ -0,0 +1,11 @@ +Excerpt: The menu layer now extends vue-seo-layer, so every page of a menu-only site takes its head from the SEO plugin. Breaking: the copied site cache purge route is gone. + +## Highlights + +- **SEO head on every page.** The layer extends vue-seo-layer: pages resolve through the SEO plugin and take their title, canonical, hreflang and JSON-LD from it. + +## Changed + +- **Breaking:** `server/api/__site__/cache/purge.post.ts`, `server/lib/siteCachePurge.ts` and `server/plugins/site-cache-purge.ts` are removed from this layer; vue-seo-layer provides the same route, library and plugin with the same environment variables. An app that imported the library from this layer imports it from vue-seo-layer. +- **Upgrade:** set `NUXT_SEO_API_BASE` when the backend is not on `http://127.0.0.1:8096`, or `seo: { cms: false }` for a site without the SEO plugin. An app without pages of its own (only `app.vue`) sets `seo: { htmlSitemap: false }`. +- Requires vue-seo-layer v0.2.0. diff --git a/.planning/phases/33-seo-output-audit-and-content/33-14-notes-vue-seo-layer-v0.2.0.md b/.planning/phases/33-seo-output-audit-and-content/33-14-notes-vue-seo-layer-v0.2.0.md new file mode 100644 index 00000000..6164a233 --- /dev/null +++ b/.planning/phases/33-seo-output-audit-and-content/33-14-notes-vue-seo-layer-v0.2.0.md @@ -0,0 +1,24 @@ +Excerpt: hreflang and one JSON-LD graph on every page, robots.txt and sitemaps served from the CMS, server-rendered breadcrumbs, pagination and HTML sitemap, a build guard against browser-only indexable pages, and a route report to the page registry. + +## Highlights + +- **The whole SEO head from the CMS.** Besides title, description, canonical and Open Graph, every server-rendered page now gets ``, hreflang links, `og:locale` and exactly one JSON-LD `@graph`, which app components extend through `useSchemaNode`. +- **Root files from the backend.** robots.txt, the sitemap index and files, llms.txt and the IndexNow key file are served from the SEO and sitemap plugins. +- **No accidental client-only pages.** The build fails when an indexable route would render only in the browser; declared and signed-in routes are sent `noindex`. + +## Added + +- ``, `` with `usePagination()` (prev and next links, `%pages%` in the CMS title), and HTML sitemap pages with a footer link (``), in English and Polish. +- The route report: at server start the site's static pages go to the CMS page registry. With `@nuxtjs/i18n` prefix strategies only the default-locale pages are reported; their translations are set in SEO › Pages. +- A startup drift check that logs when the CMS's URL patterns, pagination or HTML sitemap paths differ from the build. +- Body link utilities for auto-links, outbound rules and image attributes. +- `seo.noindexRoutes`, `seo.htmlSitemap`, `seo.pagination` and `seo.routeReport` options. + +## Changed + +- **Upgrade:** set `NUXT_SEO_ROUTE_REPORT_TOKEN` (or keep `NUXT_SEO_NOTFOUND_TOKEN`), declare browser-only routes in `seo.noindexRoutes` or render them on the server, and remove nginx locations for `/sitemap*` and `/robots.txt` so the site serves them. An app with `@nuxtjs/i18n` gives each locale its full HTML sitemap path in `seo.htmlSitemap.paths`. An app without pages of its own sets `seo: { htmlSitemap: false }`. +- Works best with sm-seo-plugin v0.2.0 and sm-sitemap-plugin v0.2.0; against an older backend the new parts stay off. + +## Fixed + +- Root files fall through to the app until the backend has given a site answer. diff --git a/.planning/phases/33-seo-output-audit-and-content/33-14-risk-max.sql b/.planning/phases/33-seo-output-audit-and-content/33-14-risk-max.sql new file mode 100644 index 00000000..2505bce7 --- /dev/null +++ b/.planning/phases/33-seo-output-audit-and-content/33-14-risk-max.sql @@ -0,0 +1,35 @@ +-- RISK-MAX check (quick task 261104-fx1): rows whose stored value is longer +-- than the max of its lagoon.Validate rule, so their next admin save fails. +-- Read-only: SELECT only. Tables a database lacks are reported as absent. +\pset format unaligned +\pset tuples_only on +\pset fieldsep '|' +DO $$ +DECLARE r record; n bigint; mx bigint; +BEGIN + FOR r IN SELECT * FROM (VALUES + ('backend_user_roles', 'name', 255, 'name'), + ('backend_user_roles', 'code', 255, 'code'), + ('backend_users', 'login', 255, 'login'), + ('backend_users', 'email', 255, 'email'), + ('jz_bm_checklists', 'description', 10000, 'description'), + ('jz_bm_checklist_items', 'notes', 10000, 'notes'), + ('golem15_feedback_submissions', 'email', 255, 'email'), + ('golem15_oauthserver_clients', 'redirect_uris (joined by newline)', 20000, $q$array_to_string(ARRAY(SELECT jsonb_array_elements_text(CASE WHEN jsonb_typeof(redirect_uris::jsonb) = 'array' THEN redirect_uris::jsonb ELSE '[]'::jsonb END)), E'\n')$q$), + ('golem15_oauthserver_clients', 'sector_identifier', 255, 'sector_identifier'), + ('golem15_oauthserver_settings', 'custom_css', 20000, 'custom_css'), + ('golem15_user_organisations', 'name', 255, 'name'), + ('golem15_user_organisations', 'description', 5000, 'description'), + ('golem15_sociallogin_settings', 'microsoft_tenant', 255, 'microsoft_tenant')) AS t(tbl, col, lim, expr) LOOP + IF to_regclass(r.tbl) IS NULL THEN + RAISE NOTICE '%|%|%|absent', r.tbl, r.col, r.lim; + CONTINUE; + END IF; + BEGIN + EXECUTE format('SELECT count(*) FILTER (WHERE char_length(v) > %s), coalesce(max(char_length(v)), 0) FROM (SELECT (%s)::text AS v FROM %I) s', r.lim, r.expr, r.tbl) INTO n, mx; + RAISE NOTICE '%|%|%|over=% longest=%', r.tbl, r.col, r.lim, n, mx; + EXCEPTION WHEN others THEN + RAISE NOTICE '%|%|%|error %', r.tbl, r.col, r.lim, SQLERRM; + END; + END LOOP; +END $$; diff --git a/.planning/phases/33-seo-output-audit-and-content/33-14-sm-demo-app-locale.patch b/.planning/phases/33-seo-output-audit-and-content/33-14-sm-demo-app-locale.patch new file mode 100644 index 00000000..72c24f44 --- /dev/null +++ b/.planning/phases/33-seo-output-audit-and-content/33-14-sm-demo-app-locale.patch @@ -0,0 +1,371 @@ +diff --git a/DEPLOY.md b/DEPLOY.md +index f2f4d0b..4209ab6 100644 +--- a/DEPLOY.md ++++ b/DEPLOY.md +@@ -707,13 +707,21 @@ the 404 log, robots.txt with AI bot switches, history and the `seo_` MCP tools ( + (`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). ++ HTML sitemap (`/sitemap`, `/pl/mapa-strony` in Polish; `demo:seed` sets both addresses in ++ **Settings › Sitemaps** while they are the plugin's defaults). ++- **Languages.** English is at `/`, Polish under `/pl/` (`vue-demo-app/nuxt.config.ts`, ++ `prefix_except_default`), the prefix the Journal, sitemap and SEO plugins give every non-default locale. ++ The language switcher links to the same page in the other language; the `i18n_redirected` cookie keeps ++ the last choice and sends a returning visitor of `/` to `/pl`. Before this release Polish was served ++ from a cookie at the English addresses, so every Polish address is new; nothing redirects the old way, ++ which had no addresses of its own. The reported pages (SEO › Pages) are the English ones; give a page ++ its Polish address there to list it in the sitemaps with hreflang. + - **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`; and from +- the build machine `SMOKE_SEO_API=… scripts/smoke-seo.sh https://demo.summercms.io` (below) ends with ++ the build machine `SMOKE_SEO_STRICT=1 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 +@@ -770,10 +778,10 @@ the registry; and a 404 reaching the 404 log (removed again). Locally after `scr + `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). ++`SMOKE_SEO_STRICT=1 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, and the demo runs it so, since the Polish addresses (`/pl/…`) the CMS names are pages of the ++site. + + ## Build + +@@ -888,12 +896,11 @@ shell on `/smbackend` and 200 JSON from its public `/smbackend/api/v1/lang`, tha + (including the client-only deck page, which carries `window.__NUXT_SITE_CONFIG__`) carries a + site-cache purge key (`cachePurgeToken`, `cacheAllowProxied`; it prints the key name only), 200 JSON from + `/_journal/api/v1/posts`, 404 for `/oauth/authorize` (it must not reach Go), the Turnstile widget +-container on `/register`, 200 on `/contact`, the Płatności tour card on `/` with the `i18n_redirected=pl` +-cookie, and a 422 `turnstile_failed` for a token-less `POST /_user/api/v1/register` (a 503 ++container on `/register`, 200 on `/contact`, the Płatności tour card on `/pl`, and a 422 `turnstile_failed` for a token-less `POST /_user/api/v1/register` (a 503 + `turnstile_unavailable` means `.env` has no `SUMMER_TURNSTILE__SECRET`). For the presentations and +-quizzes it checks the `/roadmap` redirect to `/p/roadmap-{hash}` (and `roadmap-pl` with the Polish +-cookie), 200 on that public deck page, the public quiz list (no answers) and the Polish quiz on +-`/quizzes`, 401 without a JWT on `/api/realtime/token` and the deck API, that the subscribe proxy never ++quizzes it checks the `/roadmap` redirect to `/p/roadmap-{hash}` (and `/pl/p/roadmap-pl-{hash}` from ++`/pl/roadmap`), 200 on that public deck page, the public quiz list (no answers) and the Polish quiz on ++`/pl/quizzes`, 401 without a JWT on `/api/realtime/token` and the deck API, that the subscribe proxy never + reaches Go, and a WebSocket upgrade on `/ws` (101 from the demo's Centrifugo; a foreign `Origin` is + refused). Last, `scripts/check-back.cjs` (the global Playwright, as `deck-snapshot.cjs`) checks the + browser history: the home page's Presentations card links straight to the deck of the language (with a +diff --git a/README.md b/README.md +index 78aa580..411e5a6 100644 +--- a/README.md ++++ b/README.md +@@ -119,6 +119,7 @@ any number of times: + twin `summercms-podstawy` (context `demo:pl`); the site lists the quizzes of its language by context; + - the subscription plans Starter (19 PLN, 5 EUR, 5 USD) and Professional (29 PLN, 7 EUR, 8 USD); + - the sitemap definition; ++- the HTML sitemap addresses `/sitemap` and `/pl/mapa-strony` (Settings › Sitemaps), while the settings hold the sitemap plugin's defaults; + - the chat family (below): personas, channels with history, a welcome DM to the visitor, the forum, + friends, the moderator group and the default moderation lists; + - the roadmap decks `roadmap` (English) and `roadmap-pl` (Polish), regenerated on every run (below). +diff --git a/plugins/demo/console/seed.go b/plugins/demo/console/seed.go +index 7d19304..1e3188d 100644 +--- a/plugins/demo/console/seed.go ++++ b/plugins/demo/console/seed.go +@@ -60,6 +60,7 @@ func runSeed(ctx context.Context, app *backpack.App, db *gorm.DB, out bonfire.Ou + {"subscription plans starter and professional", created(seedPlans)}, + {"feedback settings", func(tx *gorm.DB) (string, error) { return seedFeedbackSettings(ctx, app, tx) }}, + {"sitemap definition", created(seedSitemap)}, ++ {"HTML sitemap addresses (en, pl)", seedSitemapHTMLPaths}, + {"chat personas", created(seedPersonas(ctx))}, + {"chat channels and history (en)", created(seedChannels(ctx, channelsEN))}, + {"chat channels and history (pl)", created(seedChannels(ctx, channelsPL))}, +@@ -362,6 +363,26 @@ func seedSitemap(tx *gorm.DB) (bool, error) { + return true, tx.Exec(`INSERT INTO golem15_sitemap_definitions (theme, data, created_at, updated_at) VALUES ('default', ?, now(), now())`, data).Error + } + ++// demoHTMLSitemapPaths are the HTML sitemap's addresses on the demo: English ++// at /sitemap and Polish under the site's /pl/ prefix (vue-demo-app ++// i18n.strategy prefix_except_default and seo.htmlSitemap.paths). ++const demoHTMLSitemapPaths = `{"en":"/sitemap","pl":"/pl/mapa-strony"}` ++ ++// seedSitemapHTMLPaths gives the HTML sitemap the demo's addresses while the ++// settings hold none or the sitemap plugin's defaults ({"pl":"/mapa-strony", ++// "en":"/sitemap"}); addresses an editor changed in Settings › Sitemaps stay. ++func seedSitemapHTMLPaths(tx *gorm.DB) (string, error) { ++ res := tx.Exec(`UPDATE golem15_sitemap_settings SET html_paths = ?::jsonb ++ WHERE id = 1 AND (html_paths IS NULL OR html_paths = '{}'::jsonb OR html_paths = '{"pl":"/mapa-strony","en":"/sitemap"}'::jsonb)`, demoHTMLSitemapPaths) ++ if res.Error != nil { ++ return "", res.Error ++ } ++ if res.RowsAffected == 0 { ++ return "exists", nil ++ } ++ return "updated", nil ++} ++ + // The plan codes are distinct from other local apps' plans: Stripe price + // lookup keys are _ per Stripe account, and a plan sync moves + // a lookup key to its own new price. +diff --git a/scripts/check-back.cjs b/scripts/check-back.cjs +index 2953394..3b8ae47 100644 +--- a/scripts/check-back.cjs ++++ b/scripts/check-back.cjs +@@ -67,7 +67,7 @@ async function cardHref(page, base) { + await page.goto(`${base}/`, { waitUntil: 'networkidle' }) + return page.evaluate(() => { + const links = Array.from(document.querySelectorAll('#tour .tour-card__link')) +- const deck = links.find((a) => /^\/(p\/roadmap|roadmap)/.test(a.getAttribute('href') || '')) ++ const deck = links.find((a) => /^\/(pl\/)?(p\/roadmap|roadmap)/.test(a.getAttribute('href') || '')) + return deck ? deck.getAttribute('href') : '' + }) + } +@@ -81,23 +81,24 @@ const TOKEN_COOKIE = 'summercms_demo_token' + async function memberHome(browser, base, host, locale, mine) { + const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } }) + const cookies = [{ name: TOKEN_COOKIE, value: 'check-back', domain: host, path: '/' }] +- if (locale) cookies.push({ name: 'i18n_redirected', value: locale, domain: host, path: '/' }) + await ctx.addCookies(cookies) + const page = await ctx.newPage() + const errors = [] + page.on('console', (m) => { if (m.type() === 'error' || /hydration/i.test(m.text())) errors.push(m.text()) }) + const what = `member home${locale ? ` (${locale})` : ''}` + try { +- const res = await page.goto(`${base}/`, { waitUntil: 'networkidle' }) ++ // Polish lives under /pl/ (prefix_except_default). ++ const prefix = locale ? `/${locale}` : '' ++ const res = await page.goto(`${base}${prefix || '/'}`, { waitUntil: 'networkidle' }) + const html = res ? await res.text() : '' +- if (!/href="\/presentations"[^>]*class="tour-card__link"/.test(html)) bad(`${what}: the server's Presentations card does not link to /presentations`) ++ if (!new RegExp(`href="${prefix}/presentations"[^>]*class="tour-card__link"`).test(html)) bad(`${what}: the server's Presentations card does not link to ${prefix}/presentations`) + if (!html.includes(`site-header__mine">${mine}<`)) bad(`${what}: the server's header has no '${mine}' link`) + const hydrated = await page.evaluate(() => ({ + card: Array.from(document.querySelectorAll('#tour .tour-card__link')).map((a) => a.getAttribute('href')), + mine: (document.querySelector('.site-header__mine') || {}).textContent || '', + })) +- if (!hydrated.card.includes('/presentations') || hydrated.card.some((h) => /^\/(p\/)?roadmap/.test(h || ''))) { +- bad(`${what}: after hydration the card links are ${hydrated.card.join(',')}, want /presentations and no deck`) ++ if (!hydrated.card.includes(`${prefix}/presentations`) || hydrated.card.some((h) => /^\/(pl\/)?(p\/)?roadmap/.test(h || ''))) { ++ bad(`${what}: after hydration the card links are ${hydrated.card.join(',')}, want ${prefix}/presentations and no deck`) + } + if (hydrated.mine.trim() !== mine) bad(`${what}: after hydration the header link reads '${hydrated.mine.trim()}', want '${mine}'`) + const mismatch = errors.filter((e) => /hydration/i.test(e)) +@@ -114,9 +115,8 @@ async function main() { + try { + // The Polish card links to the Polish deck. + const pl = await browser.newContext({ viewport: { width: 1440, height: 900 } }) +- await pl.addCookies([{ name: 'i18n_redirected', value: 'pl', domain: host, path: '/' }]) +- const plHref = await cardHref(await pl.newPage(), base) +- if (!/^\/p\/roadmap-pl-[0-9a-f]+$/.test(plHref)) bad(`pl home: the Presentations card links to '${plHref}', want /p/roadmap-pl-{hash}`) ++ const plHref = await cardHref(await pl.newPage(), `${base}/pl`) ++ if (!/^\/pl\/p\/roadmap-pl-[0-9a-f]+$/.test(plHref)) bad(`pl home: the Presentations card links to '${plHref}', want /pl/p/roadmap-pl-{hash}`) + await pl.close() + + const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } }) +diff --git a/scripts/check-live.sh b/scripts/check-live.sh +index c2bed87..b91e815 100755 +--- a/scripts/check-live.sh ++++ b/scripts/check-live.sh +@@ -179,14 +179,14 @@ code="$(get /contact)" + no_admin_link /contact + no_purge_key /contact + +-# The @nuxtjs/i18n cookie that selects the language (vue-demo-app/nuxt.config.ts). +-code="$(get / -b i18n_redirected=pl)" ++# Polish lives under /pl/ (vue-demo-app/nuxt.config.ts, prefix_except_default). ++code="$(get /pl)" + if [[ "$code" != 200 ]]; then +- bad "GET $BASE/ (pl cookie): status $code, want 200" ++ bad "GET $BASE/pl: status $code, want 200" + else +- grep -qF 'Płatności' "$TMP/body" || bad "GET $BASE/ (pl cookie): no Płatności tour card (Polish not served)" +- no_admin_link "/ (pl cookie)" +- no_purge_key "/ (pl cookie)" ++ grep -qF 'Płatności' "$TMP/body" || bad "GET $BASE/pl: no Płatności tour card (Polish not served)" ++ no_admin_link "/pl" ++ no_purge_key "/pl" + fi + + email="check-live-$(date +%s)-$RANDOM@example.test" +@@ -200,15 +200,17 @@ else + grep -qF turnstile_failed "$TMP/body" || bad "POST $BASE/_user/api/v1/register without a token: 422 but not turnstile_failed ($(head -c 200 "$TMP/body"))" + fi + +-# Presentations (D-14, D-16): /roadmap redirects to the deck of the language. ++# Presentations (D-14, D-16): /roadmap redirects to the deck of the language ++# (/pl/roadmap to the Polish deck under /pl/). + for lang in en pl; do + want=/p/roadmap- +- [[ "$lang" == pl ]] && want=/p/roadmap-pl- +- code="$(get /roadmap -b "i18n_redirected=$lang")" ++ path=/roadmap ++ [[ "$lang" == pl ]] && want=/pl/p/roadmap-pl- && path=/pl/roadmap ++ code="$(get "$path")" + location="$(header Location)" + location="${location#"$BASE"}" + if [[ "$code" != 302 || "$location" != "$want"* ]]; then +- bad "GET $BASE/roadmap ($lang cookie): status $code location '$location', want 302 $want{hash}" ++ bad "GET $BASE$path: status $code location '$location', want 302 $want{hash}" + elif [[ "$lang" == en ]]; then + code="$(get "$location")" + [[ "$code" == 200 ]] || bad "GET $BASE$location: status $code, want 200 (the public deck page)" +@@ -225,13 +227,13 @@ else + grep -qF '"summercms-basics"' "$TMP/body" || bad "GET $BASE/_demo/api/v1/quizzes?context=demo:en: no summercms-basics" + ! grep -qiF 'is_correct' "$TMP/body" || bad "GET $BASE/_demo/api/v1/quizzes: the public list leaks correctness" + fi +-code="$(get /quizzes -b i18n_redirected=pl)" ++code="$(get /pl/quizzes)" + if [[ "$code" != 200 ]]; then +- bad "GET $BASE/quizzes (pl cookie): status $code, want 200" ++ bad "GET $BASE/pl/quizzes: status $code, want 200" + else +- grep -qF 'Podstawy SummerCMS' "$TMP/body" || bad "GET $BASE/quizzes (pl cookie): the Polish quiz is not listed" +- no_admin_link "/quizzes (pl cookie)" +- no_purge_key "/quizzes (pl cookie)" ++ grep -qF 'Podstawy SummerCMS' "$TMP/body" || bad "GET $BASE/pl/quizzes: the Polish quiz is not listed" ++ no_admin_link "/pl/quizzes" ++ no_purge_key "/pl/quizzes" + fi + + # Realtime (D-17) and the deck API (D-15) need a JWT. +diff --git a/scripts/check-local.sh b/scripts/check-local.sh +index 7a81987..1b1d808 100755 +--- a/scripts/check-local.sh ++++ b/scripts/check-local.sh +@@ -107,8 +107,8 @@ cd "$(dirname "$0")/.." + GO="http://${GO_ADDR:-127.0.0.1:8097}" + NUXT="http://${NUXT_ADDR:-127.0.0.1:3020}" + ALL_CHECKS=(turnstile tour journal contact reset roadmap realtime decks quizzes back remote chat forum friends call herald chatlive callmedia moderation) +-# The @nuxtjs/i18n cookie that selects the language (nuxt.config.ts). +-LOCALE_COOKIE=i18n_redirected ++# Polish lives under /pl/ (vue-demo-app/nuxt.config.ts, prefix_except_default). ++PL=/pl + # Cloudflare's test token: the always-pass test secret accepts it. + DUMMY_TOKEN=XXXX.DUMMY.TOKEN.XXXX + +@@ -191,24 +191,24 @@ check_tour() { + ! grep -qx Subscriptions <<<"$titles" || bad "tour: a tour card is titled Subscriptions, want Payments" + fi + +- code="$(request GET "$NUXT/" -b "$LOCALE_COOKIE=pl")" ++ code="$(request GET "$NUXT$PL")" + if [[ "$code" != 200 ]]; then +- bad "tour: GET / (pl) status $code, want 200" ++ bad "tour: GET $PL status $code, want 200" + else +- card_titles | grep -qx 'Płatności' || bad "tour: GET / with the pl cookie has no Płatności card" +- card_titles | grep -qx 'Czat' || bad "tour: GET / with the pl cookie has no Czat card" ++ card_titles | grep -qx 'Płatności' || bad "tour: GET $PL has no Płatności card" ++ card_titles | grep -qx 'Czat' || bad "tour: GET $PL has no Czat card" + for want in Forum Znajomi 'Rozmowy wideo'; do +- card_titles | grep -qx "$want" || bad "tour: GET / with the pl cookie has no $want card" ++ card_titles | grep -qx "$want" || bad "tour: GET $PL has no $want card" + done +- grep -qF 'Dane mogą zostać zresetowane w każdej chwili' "$TMP/body" || bad "tour: GET / with the pl cookie has no Polish banner" +- grep -q 'lang="pl' "$TMP/body" || bad "tour: GET / with the pl cookie does not render html lang=pl" ++ grep -qF 'Dane mogą zostać zresetowane w każdej chwili' "$TMP/body" || bad "tour: GET $PL has no Polish banner" ++ grep -q 'lang="pl' "$TMP/body" || bad "tour: GET $PL does not render html lang=pl" + fi + +- code="$(request GET "$NUXT/faq" -b "$LOCALE_COOKIE=pl")" ++ code="$(request GET "$NUXT$PL/faq")" + if [[ "$code" != 200 ]]; then +- bad "tour: GET /faq (pl) status $code, want 200" ++ bad "tour: GET $PL/faq status $code, want 200" + else +- grep -qF 'Czym jest SummerCMS?' "$TMP/body" || bad "tour: GET /faq with the pl cookie has no Polish question" ++ grep -qF 'Czym jest SummerCMS?' "$TMP/body" || bad "tour: GET $PL/faq has no Polish question" + fi + } + +@@ -221,11 +221,11 @@ check_journal() { + json_has subtitle description avatar || bad "journal: the author payload lacks subtitle, description or avatar ($(head -c 300 "$TMP/body"))" + fi + +- code="$(request GET "$NUXT/blog/welcome-to-summercms" -b "$LOCALE_COOKIE=pl")" ++ code="$(request GET "$NUXT$PL/blog/welcome-to-summercms")" + if [[ "$code" != 200 ]]; then +- bad "journal: GET /blog/welcome-to-summercms (pl) status $code, want 200" ++ bad "journal: GET $PL/blog/welcome-to-summercms status $code, want 200" + else +- grep -qF 'Witaj w SummerCMS' "$TMP/body" || bad "journal: the post with the pl cookie has no Polish title" ++ grep -qF 'Witaj w SummerCMS' "$TMP/body" || bad "journal: the Polish post has no Polish title" + grep -qF 'Golem15' "$TMP/body" || bad "journal: the post has no author card subtitle" + fi + +@@ -305,9 +305,9 @@ check_roadmap() { + bad "roadmap: the roadmap decks are not published (run scripts/dev.sh seed)" + return + fi +- got="$(location_of GET "$NUXT/roadmap" -b "$LOCALE_COOKIE=pl")" +- [[ "$(cat "$TMP/status")" == 302 && "$got" == "/p/roadmap-pl-$pl" ]] || +- bad "roadmap: GET /roadmap (pl): status $(cat "$TMP/status") location '$got', want 302 /p/roadmap-pl-$pl" ++ got="$(location_of GET "$NUXT$PL/roadmap")" ++ [[ "$(cat "$TMP/status")" == 302 && "$got" == "$PL/p/roadmap-pl-$pl" ]] || ++ bad "roadmap: GET $PL/roadmap: status $(cat "$TMP/status") location '$got', want 302 $PL/p/roadmap-pl-$pl" + got="$(location_of GET "$NUXT/roadmap")" + [[ "$(cat "$TMP/status")" == 302 && "$got" == "/p/roadmap-$en" ]] || + bad "roadmap: GET /roadmap: status $(cat "$TMP/status") location '$got', want 302 /p/roadmap-$en" +@@ -444,9 +444,9 @@ check_quizzes() { + fi + code="$(request GET "$GO/_demo/api/v1/quizzes?context=xx")" + [[ "$code" == 422 ]] || bad "quizzes: context xx: status $code, want 422" +- code="$(request GET "$NUXT/quizzes" -b "$LOCALE_COOKIE=pl")" ++ code="$(request GET "$NUXT$PL/quizzes")" + if [[ "$code" != 200 ]]; then +- bad "quizzes: GET /quizzes (pl) on Nuxt: status $code, want 200" ++ bad "quizzes: GET $PL/quizzes on Nuxt: status $code, want 200" + else + grep -qF 'Podstawy SummerCMS' "$TMP/body" || bad "quizzes: GET /quizzes with the pl cookie does not list the Polish quiz" + fi +diff --git a/seed_test.go b/seed_test.go +index 42f5806..77b7042 100644 +--- a/seed_test.go ++++ b/seed_test.go +@@ -2,6 +2,7 @@ package main + + import ( + "bytes" ++ "encoding/json" + "os" + "path/filepath" + "strconv" +@@ -184,6 +185,36 @@ var seededContent = []string{ + "golem15_pgsub_subscription_plans", "golem15_sitemap_definitions", "golem15_feedback_settings", "users", + } + ++// TestSeedHTMLSitemapPaths pins the HTML sitemap's addresses: demo:seed ++// sets /sitemap and /pl/mapa-strony over the sitemap plugin's defaults, and ++// keeps addresses an editor changed. ++func TestSeedHTMLSitemapPaths(t *testing.T) { ++ d := bootDemo(t, visitorEnv, nil) ++ mustSeed(t, d) ++ paths := func() map[string]string { ++ t.Helper() ++ var raw string ++ if err := d.DB.Raw(`SELECT html_paths::text FROM golem15_sitemap_settings WHERE id = 1`).Scan(&raw).Error; err != nil { ++ t.Fatal(err) ++ } ++ out := map[string]string{} ++ if err := json.Unmarshal([]byte(raw), &out); err != nil { ++ t.Fatalf("html_paths %q: %v", raw, err) ++ } ++ return out ++ } ++ if got := paths(); got["en"] != "/sitemap" || got["pl"] != "/pl/mapa-strony" || len(got) != 2 { ++ t.Fatalf("html_paths after demo:seed = %v, want /sitemap and /pl/mapa-strony", got) ++ } ++ if err := d.DB.Exec(`UPDATE golem15_sitemap_settings SET html_paths = '{"en":"/site-map","pl":"/pl/mapa"}'::jsonb WHERE id = 1`).Error; err != nil { ++ t.Fatal(err) ++ } ++ mustSeed(t, d) ++ if got := paths(); got["en"] != "/site-map" || got["pl"] != "/pl/mapa" { ++ t.Fatalf("html_paths after a second demo:seed = %v, want the editor's addresses kept", got) ++ } ++} ++ + // TestSeedLocales pins D-13 and the journal author (todo esrp4qtx): after + // demo:seed the home FAQ exists in English and Polish, the main menu has + // its entries, every demo post has a Polish title and the SummerCMS Team diff --git a/.planning/phases/33-seo-output-audit-and-content/33-14-vue-demo-app-locale.patch b/.planning/phases/33-seo-output-audit-and-content/33-14-vue-demo-app-locale.patch new file mode 100644 index 00000000..6939c784 --- /dev/null +++ b/.planning/phases/33-seo-output-audit-and-content/33-14-vue-demo-app-locale.patch @@ -0,0 +1,757 @@ +diff --git a/app/components/BackendCta.vue b/app/components/BackendCta.vue +index 962aab2..2823553 100644 +--- a/app/components/BackendCta.vue ++++ b/app/components/BackendCta.vue +@@ -10,5 +10,5 @@ const { t } = useI18n() + + + +diff --git a/app/components/DemoBanner.vue b/app/components/DemoBanner.vue +index 0534b1f..24806c5 100644 +--- a/app/components/DemoBanner.vue ++++ b/app/components/DemoBanner.vue +@@ -13,7 +13,7 @@ const { t } = useI18n() +

+

+ {{ t('banner.strong') }} {{ t('banner.compact') }} +- {{ t('banner.home') }} ++ {{ t('banner.home') }} +

+
+
+diff --git a/app/components/Journal/AuthorCard.vue b/app/components/Journal/AuthorCard.vue +index 61554a7..4e73eb6 100644 +--- a/app/components/Journal/AuthorCard.vue ++++ b/app/components/Journal/AuthorCard.vue +@@ -20,7 +20,7 @@ const href = computed(() => links.authorLink(props.author.slug)) +
+

{{ t('journalAuthor.about') }}

+

+- {{ name }} ++ {{ name }} + + {{ author.subtitle }} +

+diff --git a/app/components/Journal/PostMeta.vue b/app/components/Journal/PostMeta.vue +index c3ec9e9..0f9dff2 100644 +--- a/app/components/Journal/PostMeta.vue ++++ b/app/components/Journal/PostMeta.vue +@@ -31,12 +31,12 @@ const { data: author } = await useAsyncData(`journal-author-card-${login}`, () = + + + +diff --git a/app/components/LanguageSwitcher.vue b/app/components/LanguageSwitcher.vue +index 4862703..e7600ea 100644 +--- a/app/components/LanguageSwitcher.vue ++++ b/app/components/LanguageSwitcher.vue +@@ -1,27 +1,22 @@ +- ++ + + + +diff --git a/app/components/SiteHeader.vue b/app/components/SiteHeader.vue +index 5b19e34..49fb013 100644 +--- a/app/components/SiteHeader.vue ++++ b/app/components/SiteHeader.vue +@@ -8,26 +8,27 @@ + + + +diff --git a/app/components/deck/DeckForm.vue b/app/components/deck/DeckForm.vue +index a1355a1..2021c84 100644 +--- a/app/components/deck/DeckForm.vue ++++ b/app/components/deck/DeckForm.vue +@@ -12,6 +12,7 @@ import { DECK_THEMES, deckApi, type DeckSlideInput } from '~/lib/api/demo' + import { parseSourceTodo, slideBodyHTML, slideSayHTML, slugify, sourceTodo } from '~/lib/deckSource' + import { formError } from '~/lib/forms' + import { renderMarkdown } from '~/lib/markdown' ++const localePath = useLocalePath() + + const props = defineProps<{ slug?: string }>() + +@@ -97,7 +98,7 @@ async function load(value: string) { + if (!slides.value.length) slides.value = [row()] + } catch (e) { + if (e instanceof ApiError && e.status === 404) { +- await navigateTo({ path: '/presentations', query: { denied: '1' } }, { replace: true }) ++ await navigateTo(localePath({ path: '/presentations', query: { denied: '1' } }), { replace: true }) + return + } + error.value = e instanceof ApiError && e.message ? e.message : t('auth.genericError') +@@ -117,7 +118,7 @@ async function save() { + await deckApi.create({ title: title.value, slug: slug.value, theme: theme.value, slides: payloadSlides() }) + } + toast.show(t('deckForm.saved')) +- await navigateTo('/presentations') ++ await navigateTo(localePath('/presentations')) + } catch (e) { + if (e instanceof ApiError && e.status === 409) { + fields.value = { slug: t('deckForm.slugTaken') } +@@ -142,7 +143,7 @@ if (props.slug) void load(props.slug) +

{{ t('mine.eyebrow') }}

+
+

{{ editing ? t('deckForm.editTitle') : t('deckForm.newTitle') }}

+- {{ t('deckForm.back') }} ++ {{ t('deckForm.back') }} +
+

{{ t('deckForm.lead') }}

+ +diff --git a/app/components/remote/RemoteDrawer.vue b/app/components/remote/RemoteDrawer.vue +index a96e7c1..11a14b9 100644 +--- a/app/components/remote/RemoteDrawer.vue ++++ b/app/components/remote/RemoteDrawer.vue +@@ -190,7 +190,7 @@ watch( + + +
+- {{ t('remote.backToMine') }} ++ {{ t('remote.backToMine') }} +

{{ t('remote.noWakeLock') }}

+
+ +diff --git a/app/composables/useDeckSession.ts b/app/composables/useDeckSession.ts +index 5efdd3a..540d6ec 100644 +--- a/app/composables/useDeckSession.ts ++++ b/app/composables/useDeckSession.ts +@@ -9,11 +9,12 @@ import { DEMO_TOKEN_COOKIE } from '~/utils/authCookie' + + export function useDeckSession() { + const route = useRoute() ++ const localePath = useLocalePath() + const token = useCookie(DEMO_TOKEN_COOKIE) + const loggedIn = computed(() => !!token.value) + + function toLogin() { +- return navigateTo({ path: '/login', query: { next: route.fullPath } }, { replace: true }) ++ return navigateTo(localePath({ path: '/login', query: { next: route.fullPath } }), { replace: true }) + } + + // The API client runs in the browser only; its hook is module state. +diff --git a/app/layouts/default.vue b/app/layouts/default.vue +index 3e4bcb0..2d9ebc9 100644 +--- a/app/layouts/default.vue ++++ b/app/layouts/default.vue +@@ -6,11 +6,14 @@ useHead({ htmlAttrs: { lang: locale } }) + // users on every page but the chat itself and the full-screen decks. + const { loggedIn } = useDemoAuth() + const route = useRoute() ++const localePath = useLocalePath() ++// The page's path without the Polish prefix (/pl/chat is /chat). ++const barePath = computed(() => route.path.replace(/^\/pl(?=\/|$)/, '') || '/') + // The incoming-call prompt (vue-chatvideo-layer) for logged-in users: it hears + // the ring through the chat store, which the floating panel or /chat runs. +-const callPath = (id: number | string) => `/call/${id}` ++const callPath = (id: number | string) => localePath(`/call/${id}`) + const floatingChat = computed( +- () => loggedIn.value && route.path !== '/chat' && !route.path.startsWith('/p/') && !route.path.startsWith('/presentations/'), ++ () => loggedIn.value && barePath.value !== '/chat' && !barePath.value.startsWith('/p/') && !barePath.value.startsWith('/presentations/'), + ) + + +diff --git a/app/pages/account/security.vue b/app/pages/account/security.vue +index d09f776..d3ce5c9 100644 +--- a/app/pages/account/security.vue ++++ b/app/pages/account/security.vue +@@ -6,6 +6,7 @@ + --> + + +@@ -31,6 +32,6 @@ if (deck.value) { +

{{ t('roadmap.unavailable') }}

+

{{ t('roadmap.opening') }}

+ +- {{ t('roadmap.back') }} ++ {{ t('roadmap.back') }} +
+ +diff --git a/app/pages/subscriptions.vue b/app/pages/subscriptions.vue +index 9a49ea2..c277f1b 100644 +--- a/app/pages/subscriptions.vue ++++ b/app/pages/subscriptions.vue +@@ -1,4 +1,5 @@ +