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
This commit is contained in:
@@ -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"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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 `<picture>`.
|
||||
- **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".
|
||||
@@ -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, `<html lang>`, 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.
|
||||
@@ -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-<type>-<n>.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. `<priority>` and `<changefreq>` 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/<name>` (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).
|
||||
@@ -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".
|
||||
@@ -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 `<p>` 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.
|
||||
@@ -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 `<picture>` 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.
|
||||
@@ -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.
|
||||
@@ -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 `<html lang>`, 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
|
||||
|
||||
- `<SeoBreadcrumbs>`, `<SeoPagination>` with `usePagination()` (prev and next links, `%pages%` in the CMS title), and HTML sitemap pages with a footer link (`<SeoSitemapLink>`), 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.
|
||||
@@ -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 $$;
|
||||
@@ -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 <code>_<currency> 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
|
||||
@@ -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()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
- <NuxtLink class="backend-cta" to="/contact">{{ short ? t('cta.button') : t('cta.link') }}</NuxtLink>
|
||||
+ <NuxtLinkLocale class="backend-cta" to="/contact">{{ short ? t('cta.button') : t('cta.link') }}</NuxtLinkLocale>
|
||||
</template>
|
||||
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()
|
||||
<div v-if="compact" class="demo-banner demo-banner--compact" role="note">
|
||||
<p>
|
||||
<strong>{{ t('banner.strong') }}</strong> {{ t('banner.compact') }}
|
||||
- <NuxtLink to="/">{{ t('banner.home') }}</NuxtLink>
|
||||
+ <NuxtLinkLocale to="/">{{ t('banner.home') }}</NuxtLinkLocale>
|
||||
</p>
|
||||
</div>
|
||||
<div v-else class="demo-banner" role="note">
|
||||
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))
|
||||
<div class="author-card__text">
|
||||
<p class="author-card__label">{{ t('journalAuthor.about') }}</p>
|
||||
<p class="author-card__name">
|
||||
- <NuxtLink v-if="href" :to="href">{{ name }}</NuxtLink>
|
||||
+ <NuxtLinkLocale v-if="href" :to="href">{{ name }}</NuxtLinkLocale>
|
||||
<template v-else>{{ name }}</template>
|
||||
<span v-if="author.subtitle" class="author-card__subtitle">{{ author.subtitle }}</span>
|
||||
</p>
|
||||
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}`, () =
|
||||
</span>
|
||||
<span v-if="authorName" class="journal-post-meta__author">
|
||||
{{ $t('journal.post.by') }}
|
||||
- <NuxtLink v-if="authorHref" :to="authorHref">{{ authorName }}</NuxtLink>
|
||||
+ <NuxtLinkLocale v-if="authorHref" :to="authorHref">{{ authorName }}</NuxtLinkLocale>
|
||||
<template v-else>{{ authorName }}</template>
|
||||
</span>
|
||||
<ul v-if="post.categories.length" class="journal-post-meta__categories">
|
||||
<li v-for="cat in post.categories" :key="cat.id">
|
||||
- <NuxtLink v-if="links.categoryLink(cat.slug)" :to="links.categoryLink(cat.slug)!">{{ cat.name }}</NuxtLink>
|
||||
+ <NuxtLinkLocale v-if="links.categoryLink(cat.slug)" :to="links.categoryLink(cat.slug)!">{{ cat.name }}</NuxtLinkLocale>
|
||||
<span v-else>{{ cat.name }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
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 @@
|
||||
-<!-- English or Polish (D-13). setLocale writes the i18n cookie, which the server reads on the next request. -->
|
||||
+<!-- English or Polish: a link to the same page in the other locale (English at /, Polish under /pl/). -->
|
||||
<script setup lang="ts">
|
||||
-const { t, locale, locales, setLocale } = useI18n()
|
||||
-// Journal posts are localized when fetched (useJournalApi), so the page data
|
||||
-// is fetched again in the new language; FAQ and menu keys carry the locale.
|
||||
-async function choose(code: string) {
|
||||
- await setLocale(code)
|
||||
- await refreshNuxtData()
|
||||
-}
|
||||
+const { t, locale, locales } = useI18n()
|
||||
+const switchLocalePath = useSwitchLocalePath()
|
||||
const options = computed(() => locales.value.map((l) => (typeof l === 'string' ? { code: l } : { code: l.code })))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="lang-switch" role="group" :aria-label="t('lang.label')">
|
||||
- <button
|
||||
+ <NuxtLink
|
||||
v-for="l in options"
|
||||
:key="l.code"
|
||||
- type="button"
|
||||
+ :to="switchLocalePath(l.code) || '/'"
|
||||
class="lang-switch__option"
|
||||
:class="{ 'lang-switch__option--active': locale === l.code }"
|
||||
- :aria-pressed="locale === l.code ? 'true' : 'false'"
|
||||
+ :aria-current="locale === l.code ? 'true' : undefined"
|
||||
:lang="l.code"
|
||||
+ :hreflang="l.code"
|
||||
:title="t(`lang.${l.code}`)"
|
||||
- @click="choose(l.code)"
|
||||
- >{{ l.code.toUpperCase() }}</button>
|
||||
+ >{{ l.code.toUpperCase() }}</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
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 @@
|
||||
<script setup lang="ts">
|
||||
const { t } = useI18n()
|
||||
const { loggedIn, logout } = useDemoAuth()
|
||||
+const localePath = useLocalePath()
|
||||
|
||||
async function signOut() {
|
||||
await logout()
|
||||
- await navigateTo('/')
|
||||
+ await navigateTo(localePath('/'))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="site-header">
|
||||
<div class="wrap site-header__inner">
|
||||
- <NuxtLink to="/" class="brand" :aria-label="t('header.home')">
|
||||
+ <NuxtLinkLocale to="/" class="brand" :aria-label="t('header.home')">
|
||||
<img src="/sun-logo.webp" alt="" width="30" height="30" class="brand__sun">
|
||||
<span>Summer<span class="brand__cms">CMS</span></span>
|
||||
<span class="brand__pill">{{ t('header.pill') }}</span>
|
||||
- </NuxtLink>
|
||||
+ </NuxtLinkLocale>
|
||||
<SiteNav :member="loggedIn" />
|
||||
<div class="site-header__tools">
|
||||
<LanguageSwitcher />
|
||||
<button v-if="loggedIn" type="button" class="link-button" @click="signOut">{{ t('header.logout') }}</button>
|
||||
- <NuxtLink v-else to="/login" class="button button--small">{{ t('header.login') }}</NuxtLink>
|
||||
+ <NuxtLinkLocale v-else to="/login" class="button button--small">{{ t('header.login') }}</NuxtLinkLocale>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
diff --git a/app/components/SiteNav.vue b/app/components/SiteNav.vue
|
||||
index 83f35f6..0d55fda 100644
|
||||
--- a/app/components/SiteNav.vue
|
||||
+++ b/app/components/SiteNav.vue
|
||||
@@ -28,7 +28,21 @@ function entryTitle(entry: { url?: string | null; title: string }) {
|
||||
}
|
||||
|
||||
const { data: menu, error } = await useMenu('main', { locale: () => locale.value })
|
||||
-const entries = computed(() => (error.value ? [] : (menu.value?.items ?? [])))
|
||||
+const localePath = useLocalePath()
|
||||
+
|
||||
+/**
|
||||
+ * A menu entry in the visitor's locale: an address on the site (a path) gets
|
||||
+ * the locale's prefix (/blog is /pl/blog in Polish); an entry that already
|
||||
+ * carries it, an absolute URL and an entry without an address stay. The
|
||||
+ * title is looked up by the entry's own address first.
|
||||
+ */
|
||||
+interface NavEntry { url?: string | null; title: string; items?: NavEntry[] }
|
||||
+function localizeEntry<T extends NavEntry>(entry: T): T {
|
||||
+ const url = entry.url
|
||||
+ const local = typeof url === 'string' && url.startsWith('/') && !url.startsWith('//') && !/^\/pl(\/|$)/.test(url) ? localePath(url) : url
|
||||
+ return { ...entry, title: entryTitle(entry), url: local, items: entry.items?.map((child) => localizeEntry(child)) }
|
||||
+}
|
||||
+const entries = computed(() => (error.value ? [] : (menu.value?.items ?? []).map((entry) => localizeEntry(entry))))
|
||||
|
||||
/**
|
||||
* The feature links after the menu entries; the last three for members only.
|
||||
@@ -37,14 +51,14 @@ const entries = computed(() => (error.value ? [] : (menu.value?.items ?? [])))
|
||||
*/
|
||||
const links = computed(() => {
|
||||
const out = [
|
||||
- { to: '/chat', label: t('header.chat'), mine: false },
|
||||
- { to: '/forum', label: t('header.forum'), mine: false },
|
||||
+ { to: localePath('/chat'), label: t('header.chat'), mine: false },
|
||||
+ { to: localePath('/forum'), label: t('header.forum'), mine: false },
|
||||
]
|
||||
if (props.member) {
|
||||
out.push(
|
||||
- { to: '/presentations', label: t('header.mine'), mine: true },
|
||||
- { to: '/friends', label: t('header.friends'), mine: true },
|
||||
- { to: '/account/security', label: t('header.security'), mine: true },
|
||||
+ { to: localePath('/presentations'), label: t('header.mine'), mine: true },
|
||||
+ { to: localePath('/friends'), label: t('header.friends'), mine: true },
|
||||
+ { to: localePath('/account/security'), label: t('header.security'), mine: true },
|
||||
)
|
||||
}
|
||||
return out
|
||||
@@ -126,15 +140,15 @@ onBeforeUnmount(() => document.removeEventListener('pointerdown', onPointerDown)
|
||||
submenu-label="Show submenu: :title"
|
||||
@update:open="(value: boolean) => setOpen(i, value)"
|
||||
>
|
||||
- <template #item="{ entry: e }">{{ entryTitle(e) }}</template>
|
||||
+ <template #item="{ entry: e }">{{ e.title }}</template>
|
||||
</MenuNavItem>
|
||||
<li v-for="link in links" :key="link.to" class="menu-nav__item" data-depth="0">
|
||||
- <NuxtLink
|
||||
+ <NuxtLinkLocale
|
||||
:to="link.to"
|
||||
:aria-current="linkActive(link.to) ? 'page' : undefined"
|
||||
class="menu-nav__link"
|
||||
:class="{ 'site-header__mine': link.mine, 'menu-nav__link--active': linkActive(link.to) }"
|
||||
- >{{ link.label }}</NuxtLink>
|
||||
+ >{{ link.label }}</NuxtLinkLocale>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
diff --git a/app/components/TourCard.vue b/app/components/TourCard.vue
|
||||
index 9a44cbd..a4a8614 100644
|
||||
--- a/app/components/TourCard.vue
|
||||
+++ b/app/components/TourCard.vue
|
||||
@@ -40,7 +40,7 @@ const ICONS: Record<string, string> = {
|
||||
</span>
|
||||
<h3 class="tour-card__title">{{ title }}</h3>
|
||||
<p class="tour-card__text">{{ text }}</p>
|
||||
- <NuxtLink v-if="available" :to="to" class="tour-card__link">{{ t('tour.try') }} <span aria-hidden="true">→</span></NuxtLink>
|
||||
+ <NuxtLinkLocale v-if="available" :to="to" class="tour-card__link">{{ t('tour.try') }} <span aria-hidden="true">→</span></NuxtLinkLocale>
|
||||
<p v-else class="tour-card__soon">{{ t('tour.soon') }}</p>
|
||||
</article>
|
||||
</template>
|
||||
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)
|
||||
<p class="eyebrow">{{ t('mine.eyebrow') }}</p>
|
||||
<div class="page-head__row">
|
||||
<h1>{{ editing ? t('deckForm.editTitle') : t('deckForm.newTitle') }}</h1>
|
||||
- <NuxtLink to="/presentations" class="button button--small button--ghost">{{ t('deckForm.back') }}</NuxtLink>
|
||||
+ <NuxtLinkLocale to="/presentations" class="button button--small button--ghost">{{ t('deckForm.back') }}</NuxtLinkLocale>
|
||||
</div>
|
||||
<p>{{ t('deckForm.lead') }}</p>
|
||||
</header>
|
||||
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(
|
||||
</li>
|
||||
</ol>
|
||||
<footer class="list-foot">
|
||||
- <NuxtLink class="back-link" to="/presentations">{{ t('remote.backToMine') }}</NuxtLink>
|
||||
+ <NuxtLinkLocale class="back-link" to="/presentations">{{ t('remote.backToMine') }}</NuxtLinkLocale>
|
||||
<p v-if="wakeLockMissing" class="hint">{{ t('remote.noWakeLock') }}</p>
|
||||
</footer>
|
||||
</template>
|
||||
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<string | null>(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/'),
|
||||
)
|
||||
</script>
|
||||
|
||||
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 @@
|
||||
-->
|
||||
<script setup lang="ts">
|
||||
import { isTotpCode, svgDataUrl, type TotpSetup, type TwoFactorStatus } from '~/lib/twoFactor'
|
||||
+const localePath = useLocalePath()
|
||||
|
||||
const { t } = useI18n()
|
||||
// A signed-in page: vue-seo-layer marks it noindex, nofollow from this meta.
|
||||
@@ -104,7 +105,7 @@ async function turnOff() {
|
||||
</header>
|
||||
|
||||
<p v-if="!loggedIn" class="notice notice--muted">
|
||||
- <NuxtLink to="/login?next=/account/security">{{ t('security.loginFirst') }}</NuxtLink>
|
||||
+ <NuxtLinkLocale :to="{ path: '/login', query: { next: localePath('/account/security') } }">{{ t('security.loginFirst') }}</NuxtLinkLocale>
|
||||
</p>
|
||||
|
||||
<div v-else class="panel">
|
||||
@@ -143,6 +144,6 @@ async function turnOff() {
|
||||
</div>
|
||||
|
||||
<!-- The account area links the friends page (D-15; the demo has no profile page). -->
|
||||
- <p v-if="loggedIn" class="muted"><NuxtLink to="/friends">{{ t('security.friendsLink') }}</NuxtLink></p>
|
||||
+ <p v-if="loggedIn" class="muted"><NuxtLinkLocale to="/friends">{{ t('security.friendsLink') }}</NuxtLinkLocale></p>
|
||||
</div>
|
||||
</template>
|
||||
diff --git a/app/pages/call/[conversation].vue b/app/pages/call/[conversation].vue
|
||||
index 7db746e..0410e25 100644
|
||||
--- a/app/pages/call/[conversation].vue
|
||||
+++ b/app/pages/call/[conversation].vue
|
||||
@@ -36,8 +36,8 @@ const loginLink = computed(() => ({ path: '/login', query: { next: route.path }
|
||||
<div v-else class="card card--static call-prompt">
|
||||
<p>{{ t('call.anonymous') }}</p>
|
||||
<div class="deck-card__actions">
|
||||
- <NuxtLink class="button" :to="registerLink">{{ t('call.registerToCall') }}</NuxtLink>
|
||||
- <NuxtLink class="button button--ghost" :to="loginLink">{{ t('header.login') }}</NuxtLink>
|
||||
+ <NuxtLinkLocale class="button" :to="registerLink">{{ t('call.registerToCall') }}</NuxtLinkLocale>
|
||||
+ <NuxtLinkLocale class="button button--ghost" :to="loginLink">{{ t('header.login') }}</NuxtLinkLocale>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
diff --git a/app/pages/chat.vue b/app/pages/chat.vue
|
||||
index c125978..1825f01 100644
|
||||
--- a/app/pages/chat.vue
|
||||
+++ b/app/pages/chat.vue
|
||||
@@ -33,15 +33,15 @@ const startCallPath = (id: number | string) => `/call/${id}?start=1`
|
||||
<ChatvideoCallButton :conversation-id="conversation.id" :call-path="startCallPath" />
|
||||
</template>
|
||||
<template #sidebar-footer>
|
||||
- <NuxtLink to="/friends" class="chat-sidebar-link">{{ t('chat.friendsLink') }}</NuxtLink>
|
||||
+ <NuxtLinkLocale to="/friends" class="chat-sidebar-link">{{ t('chat.friendsLink') }}</NuxtLinkLocale>
|
||||
</template>
|
||||
</ChatPage>
|
||||
</ClientOnly>
|
||||
<div v-else class="card card--static chat-prompt">
|
||||
<p>{{ t('chat.anonymous') }}</p>
|
||||
<div class="deck-card__actions">
|
||||
- <NuxtLink class="button" :to="registerLink">{{ t('chat.registerToChat') }}</NuxtLink>
|
||||
- <NuxtLink class="button button--ghost" :to="loginLink">{{ t('header.login') }}</NuxtLink>
|
||||
+ <NuxtLinkLocale class="button" :to="registerLink">{{ t('chat.registerToChat') }}</NuxtLinkLocale>
|
||||
+ <NuxtLinkLocale class="button button--ghost" :to="loginLink">{{ t('header.login') }}</NuxtLinkLocale>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
diff --git a/app/pages/connect.vue b/app/pages/connect.vue
|
||||
index 2c5f540..2434fe1 100644
|
||||
--- a/app/pages/connect.vue
|
||||
+++ b/app/pages/connect.vue
|
||||
@@ -8,6 +8,7 @@
|
||||
// the admin shown; when the link changed meanwhile the server answers 409
|
||||
// and the page loads the request again.
|
||||
import { BACKEND_LINK_CHANGED, consentDecisionBody } from '~/lib/consent'
|
||||
+const localePath = useLocalePath()
|
||||
|
||||
type ConsentRequest = {
|
||||
request_id: string
|
||||
@@ -51,7 +52,7 @@ async function load() {
|
||||
return
|
||||
}
|
||||
if (!loggedIn.value) {
|
||||
- await navigateTo('/login?next=' + encodeURIComponent('/connect?request=' + requestId.value))
|
||||
+ await navigateTo(localePath({ path: '/login', query: { next: localePath({ path: '/connect', query: { request: requestId.value } }) } }))
|
||||
return
|
||||
}
|
||||
try {
|
||||
@@ -60,7 +61,7 @@ async function load() {
|
||||
} catch (e) {
|
||||
const status = statusOf(e)
|
||||
if (status === 401) {
|
||||
- await navigateTo('/login?next=' + encodeURIComponent('/connect?request=' + requestId.value))
|
||||
+ await navigateTo(localePath({ path: '/login', query: { next: localePath({ path: '/connect', query: { request: requestId.value } }) } }))
|
||||
return
|
||||
}
|
||||
state.value = status === 404 || status === 410 ? 'expired' : 'error'
|
||||
diff --git a/app/pages/contact.vue b/app/pages/contact.vue
|
||||
index 0ae15b8..e608133 100644
|
||||
--- a/app/pages/contact.vue
|
||||
+++ b/app/pages/contact.vue
|
||||
@@ -60,7 +60,7 @@ async function submit() {
|
||||
<h1>{{ t('contact.heading') }}</h1>
|
||||
<div v-if="sent">
|
||||
<p class="notice" role="status">{{ t('contact.thanks') }}</p>
|
||||
- <NuxtLink to="/" class="button">{{ t('contact.backHome') }}</NuxtLink>
|
||||
+ <NuxtLinkLocale to="/" class="button">{{ t('contact.backHome') }}</NuxtLinkLocale>
|
||||
</div>
|
||||
<form v-else class="form" @submit.prevent="submit">
|
||||
<p v-if="error" class="form__error" role="alert">{{ error }}</p>
|
||||
diff --git a/app/pages/forum/index.vue b/app/pages/forum/index.vue
|
||||
index f0b2536..5506d61 100644
|
||||
--- a/app/pages/forum/index.vue
|
||||
+++ b/app/pages/forum/index.vue
|
||||
@@ -31,8 +31,8 @@ const threadPath = (id: number) => `/forum/t/${id}`
|
||||
<div v-else class="card card--static forum-prompt">
|
||||
<p>{{ t('forum.anonymous') }}</p>
|
||||
<div class="deck-card__actions">
|
||||
- <NuxtLink class="button" :to="registerLink">{{ t('forum.registerToJoin') }}</NuxtLink>
|
||||
- <NuxtLink class="button button--ghost" :to="loginLink">{{ t('header.login') }}</NuxtLink>
|
||||
+ <NuxtLinkLocale class="button" :to="registerLink">{{ t('forum.registerToJoin') }}</NuxtLinkLocale>
|
||||
+ <NuxtLinkLocale class="button button--ghost" :to="loginLink">{{ t('header.login') }}</NuxtLinkLocale>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
diff --git a/app/pages/forum/t/[id].vue b/app/pages/forum/t/[id].vue
|
||||
index 8c5a66f..786a0d7 100644
|
||||
--- a/app/pages/forum/t/[id].vue
|
||||
+++ b/app/pages/forum/t/[id].vue
|
||||
@@ -49,8 +49,8 @@ onMounted(async () => {
|
||||
<div v-else class="card card--static forum-prompt">
|
||||
<p>{{ t('forum.anonymous') }}</p>
|
||||
<div class="deck-card__actions">
|
||||
- <NuxtLink class="button" :to="registerLink">{{ t('forum.registerToJoin') }}</NuxtLink>
|
||||
- <NuxtLink class="button button--ghost" :to="loginLink">{{ t('header.login') }}</NuxtLink>
|
||||
+ <NuxtLinkLocale class="button" :to="registerLink">{{ t('forum.registerToJoin') }}</NuxtLinkLocale>
|
||||
+ <NuxtLinkLocale class="button button--ghost" :to="loginLink">{{ t('header.login') }}</NuxtLinkLocale>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
diff --git a/app/pages/friends.vue b/app/pages/friends.vue
|
||||
index 11d9bd9..0a452fe 100644
|
||||
--- a/app/pages/friends.vue
|
||||
+++ b/app/pages/friends.vue
|
||||
@@ -30,8 +30,8 @@ const loginLink = { path: '/login', query: { next: '/friends' } }
|
||||
<div v-else class="card card--static friends-prompt">
|
||||
<p>{{ t('friends.anonymous') }}</p>
|
||||
<div class="deck-card__actions">
|
||||
- <NuxtLink class="button" :to="registerLink">{{ t('friends.registerToJoin') }}</NuxtLink>
|
||||
- <NuxtLink class="button button--ghost" :to="loginLink">{{ t('header.login') }}</NuxtLink>
|
||||
+ <NuxtLinkLocale class="button" :to="registerLink">{{ t('friends.registerToJoin') }}</NuxtLinkLocale>
|
||||
+ <NuxtLinkLocale class="button button--ghost" :to="loginLink">{{ t('header.login') }}</NuxtLinkLocale>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
diff --git a/app/pages/link-admin.vue b/app/pages/link-admin.vue
|
||||
index e4205fb..1c5cb05 100644
|
||||
--- a/app/pages/link-admin.vue
|
||||
+++ b/app/pages/link-admin.vue
|
||||
@@ -157,7 +157,7 @@ onMounted(load)
|
||||
</p>
|
||||
<button type="button" class="button" :disabled="busy" @click="confirm">{{ t('linkAdmin.confirm') }}</button>
|
||||
</template>
|
||||
- <NuxtLink v-else class="button" :to="signIn">{{ t('linkAdmin.signIn') }}</NuxtLink>
|
||||
+ <NuxtLinkLocale v-else class="button" :to="signIn">{{ t('linkAdmin.signIn') }}</NuxtLinkLocale>
|
||||
<p v-if="message" class="form__error" role="alert">{{ message }}</p>
|
||||
</div>
|
||||
</template>
|
||||
diff --git a/app/pages/login.vue b/app/pages/login.vue
|
||||
index fba22a4..6aea037 100644
|
||||
--- a/app/pages/login.vue
|
||||
+++ b/app/pages/login.vue
|
||||
@@ -6,6 +6,7 @@ const { t } = useI18n()
|
||||
definePageMeta({ seo: { robots: 'noindex, nofollow' } })
|
||||
useSeoMeta({ title: () => t('auth.login.title') })
|
||||
const route = useRoute()
|
||||
+const localePath = useLocalePath()
|
||||
const { loggedIn, login, verifyChallenge, recoverChallenge, logout } = useDemoAuth()
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
@@ -24,7 +25,7 @@ const appAvailable = computed(() => !!challenge.value?.available_methods.include
|
||||
|
||||
function nextPath() {
|
||||
const next = route.query.next
|
||||
- return typeof next === 'string' && next.startsWith('/') && !next.startsWith('//') ? next : '/subscriptions'
|
||||
+ return typeof next === 'string' && next.startsWith('/') && !next.startsWith('//') ? next : localePath('/subscriptions')
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
@@ -108,7 +109,7 @@ function startOver() {
|
||||
<TurnstileWidget ref="widget" @token="turnstileToken = $event" />
|
||||
<button class="button" :disabled="busy || !turnstileToken">{{ busy ? t('auth.login.busy') : t('auth.login.submit') }}</button>
|
||||
<p v-if="error" class="form__error" role="alert">{{ error }}</p>
|
||||
- <p class="muted">{{ t('auth.login.noAccount') }} <NuxtLink to="/register">{{ t('auth.login.createOne') }}</NuxtLink></p>
|
||||
+ <p class="muted">{{ t('auth.login.noAccount') }} <NuxtLinkLocale to="/register">{{ t('auth.login.createOne') }}</NuxtLinkLocale></p>
|
||||
</form>
|
||||
</div>
|
||||
<aside class="card card--static login__about">
|
||||
diff --git a/app/pages/presentations/[slug]/index.vue b/app/pages/presentations/[slug]/index.vue
|
||||
index fc70adb..65696fa 100644
|
||||
--- a/app/pages/presentations/[slug]/index.vue
|
||||
+++ b/app/pages/presentations/[slug]/index.vue
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
import { forgetPilot, rememberPilot } from '~/lib/pilot'
|
||||
import { openChannel, type ChannelHandle } from '~/lib/realtime'
|
||||
import speakerCss from '~/assets/deck/deck-speaker.css?inline'
|
||||
+const localePath = useLocalePath()
|
||||
|
||||
const SpeakerTools = defineAsyncComponent(() => import('~/components/deck/SpeakerTools.vue'))
|
||||
|
||||
@@ -380,7 +381,7 @@ function syncRemote() {
|
||||
|
||||
function closeTab() {
|
||||
window.close()
|
||||
- void navigateTo('/presentations', { replace: true })
|
||||
+ void navigateTo(localePath('/presentations'), { replace: true })
|
||||
}
|
||||
|
||||
function clearTimer() {
|
||||
@@ -473,7 +474,7 @@ async function load(slug: string) {
|
||||
clearTimer()
|
||||
if (err instanceof ApiError && err.status === 404) {
|
||||
forgetPilot(slug)
|
||||
- await navigateTo({ path: '/presentations', query: { denied: '1' } }, { replace: true })
|
||||
+ await navigateTo(localePath({ path: '/presentations', query: { denied: '1' } }), { replace: true })
|
||||
return
|
||||
}
|
||||
if (err instanceof ApiError && err.status === 401) return
|
||||
diff --git a/app/pages/presentations/[slug]/pilot.vue b/app/pages/presentations/[slug]/pilot.vue
|
||||
index a4ed87e..0af4493 100644
|
||||
--- a/app/pages/presentations/[slug]/pilot.vue
|
||||
+++ b/app/pages/presentations/[slug]/pilot.vue
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
} from '~/lib/deck/remote'
|
||||
import { forgetPilot, rememberPilot } from '~/lib/pilot'
|
||||
import { openChannel, type ChannelHandle, type ConnectionState } from '~/lib/realtime'
|
||||
+const localePath = useLocalePath()
|
||||
|
||||
definePageMeta({ layout: false })
|
||||
|
||||
@@ -444,7 +445,7 @@ async function load(slug: string) {
|
||||
clearTimer()
|
||||
if (err instanceof ApiError && err.status === 404) {
|
||||
forgetPilot(slug)
|
||||
- await navigateTo({ path: '/presentations', query: { denied: '1' } }, { replace: true })
|
||||
+ await navigateTo(localePath({ path: '/presentations', query: { denied: '1' } }), { replace: true })
|
||||
return
|
||||
}
|
||||
if (err instanceof ApiError && err.status === 401) return
|
||||
diff --git a/app/pages/presentations/index.vue b/app/pages/presentations/index.vue
|
||||
index 94c353a..1f4416c 100644
|
||||
--- a/app/pages/presentations/index.vue
|
||||
+++ b/app/pages/presentations/index.vue
|
||||
@@ -67,7 +67,7 @@ onMounted(() => {
|
||||
<p class="eyebrow">{{ t('mine.eyebrow') }}</p>
|
||||
<div class="page-head__row">
|
||||
<h1>{{ t('mine.title') }}</h1>
|
||||
- <NuxtLink to="/presentations/new" class="button">{{ t('mine.newDeck') }}</NuxtLink>
|
||||
+ <NuxtLinkLocale to="/presentations/new" class="button">{{ t('mine.newDeck') }}</NuxtLinkLocale>
|
||||
</div>
|
||||
<p>{{ t('mine.lead') }}</p>
|
||||
<p class="notice notice--muted">{{ t('mine.privateNote') }}</p>
|
||||
@@ -103,9 +103,9 @@ onMounted(() => {
|
||||
{{ t('mine.pilot') }}
|
||||
<span class="sr-only">{{ t('mine.onPhone') }}</span>
|
||||
</a>
|
||||
- <NuxtLink v-if="!card.is_published" class="button button--small button--ghost" :to="editHref(card.slug)">
|
||||
+ <NuxtLinkLocale v-if="!card.is_published" class="button button--small button--ghost" :to="editHref(card.slug)">
|
||||
{{ t('mine.edit') }}
|
||||
- </NuxtLink>
|
||||
+ </NuxtLinkLocale>
|
||||
<a
|
||||
v-if="card.is_published && card.public_path"
|
||||
class="button button--small button--ghost"
|
||||
diff --git a/app/pages/quizzes/[slug].vue b/app/pages/quizzes/[slug].vue
|
||||
index 0170ae2..f319dbe 100644
|
||||
--- a/app/pages/quizzes/[slug].vue
|
||||
+++ b/app/pages/quizzes/[slug].vue
|
||||
@@ -171,7 +171,7 @@ const score = (a: { score_points: number; max_points: number }) => `${Math.round
|
||||
<p class="eyebrow">{{ t('quiz.eyebrow') }}</p>
|
||||
<div class="page-head__row">
|
||||
<h1>{{ quiz?.title ?? t('quiz.listTitle') }}</h1>
|
||||
- <NuxtLink to="/quizzes" class="button button--small button--ghost">{{ t('quiz.backToList') }}</NuxtLink>
|
||||
+ <NuxtLinkLocale to="/quizzes" class="button button--small button--ghost">{{ t('quiz.backToList') }}</NuxtLinkLocale>
|
||||
</div>
|
||||
<p v-if="quiz?.description">{{ quiz.description }}</p>
|
||||
</header>
|
||||
@@ -248,7 +248,7 @@ const score = (a: { score_points: number; max_points: number }) => `${Math.round
|
||||
<p v-if="actionError" class="form__error" role="alert">{{ actionError }}</p>
|
||||
<div class="quiz-actions">
|
||||
<button class="button" type="button" :disabled="busy" @click="start">{{ t('quiz.again') }}</button>
|
||||
- <NuxtLink to="/quizzes" class="button button--ghost">{{ t('quiz.backToList') }}</NuxtLink>
|
||||
+ <NuxtLinkLocale to="/quizzes" class="button button--ghost">{{ t('quiz.backToList') }}</NuxtLinkLocale>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
diff --git a/app/pages/quizzes/index.vue b/app/pages/quizzes/index.vue
|
||||
index df5e639..a086741 100644
|
||||
--- a/app/pages/quizzes/index.vue
|
||||
+++ b/app/pages/quizzes/index.vue
|
||||
@@ -63,10 +63,10 @@ const registerLink = (slug: string) => ({ path: '/register', query: { next: quiz
|
||||
<p class="deck-card__meta">{{ t('quiz.questions', { n: q.question_count }) }}</p>
|
||||
</div>
|
||||
<div class="deck-card__actions">
|
||||
- <NuxtLink v-if="loggedIn" class="button button--small" :to="quizPath(q.slug)">{{ t('quiz.start') }}</NuxtLink>
|
||||
+ <NuxtLinkLocale v-if="loggedIn" class="button button--small" :to="quizPath(q.slug)">{{ t('quiz.start') }}</NuxtLinkLocale>
|
||||
<template v-else>
|
||||
- <NuxtLink class="button button--small" :to="registerLink(q.slug)">{{ t('quiz.registerToTake') }}</NuxtLink>
|
||||
- <NuxtLink class="button button--small button--ghost" :to="{ path: '/login', query: { next: quizPath(q.slug) } }">{{ t('header.login') }}</NuxtLink>
|
||||
+ <NuxtLinkLocale class="button button--small" :to="registerLink(q.slug)">{{ t('quiz.registerToTake') }}</NuxtLinkLocale>
|
||||
+ <NuxtLinkLocale class="button button--small button--ghost" :to="{ path: '/login', query: { next: quizPath(q.slug) } }">{{ t('header.login') }}</NuxtLinkLocale>
|
||||
</template>
|
||||
</div>
|
||||
</article>
|
||||
diff --git a/app/pages/register.vue b/app/pages/register.vue
|
||||
index fc5836c..a4bb09d 100644
|
||||
--- a/app/pages/register.vue
|
||||
+++ b/app/pages/register.vue
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
+const localePath = useLocalePath()
|
||||
// Self-registration (D-05), ported from bm's RegisterView: the user plugin's
|
||||
// POST /_user/api/v1/register behind Cloudflare Turnstile (D-07). No field is
|
||||
// pre-filled and there is no e-mail verification step.
|
||||
@@ -41,7 +42,7 @@ async function submit() {
|
||||
},
|
||||
turnstileToken.value,
|
||||
)
|
||||
- if (res.signedIn) await navigateTo(next.value || '/subscriptions')
|
||||
+ if (res.signedIn) await navigateTo(next.value || localePath('/subscriptions'))
|
||||
else done.value = res.message || t('auth.register.done')
|
||||
} catch (e) {
|
||||
error.value = authErrors(e, t, t('auth.genericError'))
|
||||
@@ -57,10 +58,10 @@ async function submit() {
|
||||
<div class="login">
|
||||
<div class="panel">
|
||||
<h1>{{ t('auth.register.title') }}</h1>
|
||||
- <p v-if="loggedIn">{{ t('auth.loggedIn') }} <NuxtLink to="/subscriptions">{{ t('auth.seePlans') }}</NuxtLink></p>
|
||||
+ <p v-if="loggedIn">{{ t('auth.loggedIn') }} <NuxtLinkLocale to="/subscriptions">{{ t('auth.seePlans') }}</NuxtLinkLocale></p>
|
||||
<div v-else-if="done">
|
||||
<p class="notice" role="status">{{ done }}</p>
|
||||
- <NuxtLink :to="loginLink" class="button">{{ t('auth.login.submit') }}</NuxtLink>
|
||||
+ <NuxtLinkLocale :to="loginLink" class="button">{{ t('auth.login.submit') }}</NuxtLinkLocale>
|
||||
</div>
|
||||
<form v-else class="form" novalidate @submit.prevent="submit">
|
||||
<p v-if="error?.message" class="form__error" role="alert">{{ error.message }}</p>
|
||||
@@ -75,7 +76,7 @@ async function submit() {
|
||||
<label>{{ t('auth.register.confirmation') }} <input v-model="confirmation" type="password" autocomplete="new-password" required></label>
|
||||
<TurnstileWidget ref="widget" @token="turnstileToken = $event" />
|
||||
<button class="button" :disabled="busy || !turnstileToken">{{ busy ? t('auth.register.busy') : t('auth.register.submit') }}</button>
|
||||
- <p class="muted">{{ t('auth.register.haveAccount') }} <NuxtLink :to="loginLink">{{ t('auth.login.submit') }}</NuxtLink></p>
|
||||
+ <p class="muted">{{ t('auth.register.haveAccount') }} <NuxtLinkLocale :to="loginLink">{{ t('auth.login.submit') }}</NuxtLinkLocale></p>
|
||||
</form>
|
||||
</div>
|
||||
<aside class="card card--static login__about">
|
||||
diff --git a/app/pages/roadmap.vue b/app/pages/roadmap.vue
|
||||
index 5b83e96..8c70b8d 100644
|
||||
--- a/app/pages/roadmap.vue
|
||||
+++ b/app/pages/roadmap.vue
|
||||
@@ -8,6 +8,7 @@
|
||||
-->
|
||||
<script setup lang="ts">
|
||||
import { DEMO_API, fetchRoadmap, roadmapPath } from '~/lib/api/demo'
|
||||
+const localePath = useLocalePath()
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
// Only a redirect to the roadmap deck (/p/..., declared noindex), so the
|
||||
@@ -19,7 +20,7 @@ const base = import.meta.server ? useRuntimeConfig().demo.apiBase : DEMO_API
|
||||
const { data: deck, error } = await useAsyncData(`roadmap-${locale.value}`, () => fetchRoadmap(base, locale.value))
|
||||
|
||||
if (deck.value) {
|
||||
- await navigateTo(roadmapPath(deck.value), { redirectCode: 302, replace: true })
|
||||
+ await navigateTo(localePath(roadmapPath(deck.value)), { redirectCode: 302, replace: true })
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -31,6 +32,6 @@ if (deck.value) {
|
||||
<p v-if="error" class="notice notice--muted">{{ t('roadmap.unavailable') }}</p>
|
||||
<p v-else>{{ t('roadmap.opening') }}</p>
|
||||
</header>
|
||||
- <NuxtLink to="/" class="button button--ghost">{{ t('roadmap.back') }}</NuxtLink>
|
||||
+ <NuxtLinkLocale to="/" class="button button--ghost">{{ t('roadmap.back') }}</NuxtLinkLocale>
|
||||
</div>
|
||||
</template>
|
||||
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 @@
|
||||
<script setup lang="ts">
|
||||
+const localePath = useLocalePath()
|
||||
const { t } = useI18n()
|
||||
// A signed-in page: vue-seo-layer marks it noindex, nofollow from this meta.
|
||||
definePageMeta({ requiresAuth: true })
|
||||
@@ -22,9 +23,9 @@ const notice = computed(() => {
|
||||
|
||||
<p v-if="notice" class="notice">{{ notice }}</p>
|
||||
<p v-if="!loggedIn" class="notice notice--muted">
|
||||
- <NuxtLink to="/login?next=/subscriptions">{{ t('subscriptions.loginFirst') }}</NuxtLink>
|
||||
+ <NuxtLinkLocale :to="{ path: '/login', query: { next: localePath('/subscriptions') } }">{{ t('subscriptions.loginFirst') }}</NuxtLinkLocale>
|
||||
{{ t('subscriptions.or') }}
|
||||
- <NuxtLink to="/register">{{ t('subscriptions.register') }}</NuxtLink>
|
||||
+ <NuxtLinkLocale to="/register">{{ t('subscriptions.register') }}</NuxtLinkLocale>
|
||||
{{ t('subscriptions.toSubscribe') }}
|
||||
</p>
|
||||
|
||||
diff --git a/nuxt.config.ts b/nuxt.config.ts
|
||||
index 6cb0c97..746cb0d 100644
|
||||
--- a/nuxt.config.ts
|
||||
+++ b/nuxt.config.ts
|
||||
@@ -74,12 +74,14 @@ export default defineNuxtConfig({
|
||||
sort: 'published_at desc',
|
||||
},
|
||||
|
||||
- // English and Polish (D-13). No path prefix, so the layers' routes keep
|
||||
- // their paths: the choice lives in a cookie that the server reads, so SSR
|
||||
- // renders the chosen language. A first visit without the cookie follows
|
||||
- // the browser's language on the home page.
|
||||
+ // English at / and Polish under /pl/ (prefix_except_default; 2026-11-04,
|
||||
+ // replacing the cookie-only no_prefix of D-13): the Journal, sitemap and
|
||||
+ // SEO plugins give every non-default locale the /<code> prefix, so the
|
||||
+ // hreflang links and the sitemap addresses they publish are pages here.
|
||||
+ // The cookie keeps the last choice; a first visit to / follows the
|
||||
+ // browser's language.
|
||||
i18n: {
|
||||
- strategy: 'no_prefix',
|
||||
+ strategy: 'prefix_except_default',
|
||||
defaultLocale: 'en',
|
||||
locales: [
|
||||
{ code: 'en', language: 'en-US', name: 'English', file: 'en.json' },
|
||||
@@ -98,10 +100,12 @@ export default defineNuxtConfig({
|
||||
// layer sends them X-Robots-Tag: noindex, nofollow and its build guard
|
||||
// (D-70) accepts them; any other browser-only route fails the build. Auth
|
||||
// and signed-in pages mark themselves in their page meta (seo.robots,
|
||||
- // requiresAuth). The HTML sitemap pages keep the layer's paths
|
||||
- // (/sitemap, /mapa-strony), which the SEO plugin's site answer names.
|
||||
+ // requiresAuth). The HTML sitemap pages are /sitemap and /pl/mapa-strony,
|
||||
+ // the paths Settings › Sitemaps names (DEPLOY.md), so the SEO plugin's site
|
||||
+ // answer and the build agree.
|
||||
seo: {
|
||||
- noindexRoutes: ['/p/**', '/presentations/**'],
|
||||
+ noindexRoutes: ['/p/**', '/presentations/**', '/pl/p/**', '/pl/presentations/**'],
|
||||
+ htmlSitemap: { paths: { en: '/sitemap', pl: '/pl/mapa-strony' } },
|
||||
},
|
||||
|
||||
runtimeConfig: {
|
||||
@@ -164,6 +168,8 @@ export default defineNuxtConfig({
|
||||
routeRules: {
|
||||
'/p/**': { ssr: false },
|
||||
'/presentations/**': { ssr: false },
|
||||
+ '/pl/p/**': { ssr: false },
|
||||
+ '/pl/presentations/**': { ssr: false },
|
||||
},
|
||||
|
||||
// tokens.css is summercms.io's (copied as is); site.css holds the demo's rules.
|
||||
@@ -26,7 +26,7 @@ Out-of-scope discoveries logged during execution; not fixed in the plan that fou
|
||||
## From 33-08 (vue-seo-layer)
|
||||
|
||||
- **`scripts/check-phase33.sh --layers` cannot pass (gate script).** Its `npm_vitest` step runs `npm run test` in each layer and then requires a vitest summary (`detect_vitest`). All four layers (vue-seo-layer, vue-journal-layer, vue-menu-layer, vue-faq-layer) run `node --test --test-reporter=tap`, so the stage refuses on the first layer although every test passes ("refuse: no all-passing vitest summary", `test=0 detect=3`).
|
||||
status: open
|
||||
status: resolved (33-14: `node_tap` with a TAP detector and self-tests in scripts/check-phase33.sh; `--all` green)
|
||||
- **What 33-08 ran instead:** each step of the stage by hand: `npm run test`, `npm run build:playground` and the smokes of all four layers, `testing/guard-check.mjs` in vue-seo-layer. All passed.
|
||||
- **Possible fix:** a `node_tap` helper in the gate (TAP `# fail 0` and no `not ok`) used for the layers, keeping `npm_vitest` for sm-seo-plugin and sm-journal-plugin.
|
||||
- **Why deferred:** 33-08 commits only to vue-seo-layer; the gate script is framework planning tooling of 33-01.
|
||||
@@ -45,7 +45,7 @@ Out-of-scope discoveries logged during execution; not fixed in the plan that fou
|
||||
- **Possible fix:** register the sitemap pages only when the app has pages of its own (in `pages:extend`, skip when the list holds only the layer's), or set `pages: true` only when the app has a pages directory.
|
||||
- **Why deferred:** a change to 33-08's registration logic in vue-seo-layer, outside 33-09's scope; the documented switch covers the case.
|
||||
- **vue-journal-layer `scripts/check-git-install.sh` will not pass as it is (release tooling, 33-14).** Its lockfile check (`grep … commit: [0-9a-f]{40}`, exactly one commit) fails once vue-seo-layer is a git dependency too (two commits in the lockfile). Its scratch app also lacks the playground config the expectations rely on: `journal.routes.search` (the search lines, already since the search page), `journal.perPage.category: 1` and the layout breadcrumb trail (33-09 lines). And during development the layer depends on `link:../vue-seo-layer`, which a git install cannot resolve.
|
||||
status: open
|
||||
status: partly resolved (33-14, vue-journal-layer e8f307b: the lockfile check counts only the layer's commit, the scratch app takes the playground's nuxt.config.ts and app.vue, `--self-test`); the `link:../vue-seo-layer` dependency is replaced by the tag pin in 33-14 Task 4, after which the script runs against the tag
|
||||
- **Possible fix (33-14):** switch the link to the git tag pin first, filter the lockfile check to the vue-journal-layer entry, and give the scratch app the playground's `journal` options and `app.vue` (or run a subset expectation file).
|
||||
- **Why deferred:** 33-14 replaces the link dependency and tags the layers; the script is part of that release step.
|
||||
- **The journal fixture API's previous_post/next_post direction is the opposite of the Go API's (test fixture).** `testing/fixture-api.mjs` answers `previous_post` with the next older post; sm-journal-plugin's `siblingPost` answers it with the next newer one (`previous` → `published_at >` ascending). The layer renders whatever it gets, so no page is wrong. The fixture is less faithful than it looks.
|
||||
@@ -105,27 +105,27 @@ Out-of-scope discoveries logged during execution; not fixed in the plan that fou
|
||||
- the sitemaps list 8 of 23 addresses under `/pl/…`, and each of those answers 404;
|
||||
- a visitor with the Polish cookie gets the English CMS title and description over a Polish body, because the resolve goes by address and answers `en`.
|
||||
`scripts/smoke-seo.sh` prints these as WARN lines; with `SMOKE_SEO_STRICT=1` they fail.
|
||||
status: open
|
||||
status: patch ready (33-14: 33-14-vue-demo-app-locale.patch and 33-14-sm-demo-app-locale.patch on top of the 33-12 demo patches; rehearsed with `SMOKE_SEO_STRICT=1 scripts/smoke-seo.sh`: ok, 23 sitemap addresses, 0 not 200); applied in Task 4
|
||||
- **Possible fix:** move the demo to `strategy: 'prefix_except_default'` (Polish at `/pl/…`, the HTML sitemap path `/pl/mapa-strony`, locale-aware links in the demo's pages and LanguageSwitcher). The other way is a framework, Journal and SEO option for "every locale at one address", which would give the site no hreflang at all.
|
||||
- **Why deferred:** it reverses a recorded demo decision and changes every Polish URL of the demo (Rule 4); 33-12 runs without a human.
|
||||
- **Additional addresses are not de-duplicated against content records (sm-sitemap-plugin).** On a database migrated from the old definition (rome), migration `202610280002` copies its url items (`/`, `/blog`, `/faq`, `/subscriptions`) into the additional addresses. Once the route report has put `/`, `/blog` and `/faq` into the page registry, `sitemap-pages-1.xml` lists each of them twice. The demo patch's DEPLOY.md removes the copied addresses after the first release. sm-grzybyfunkcjonalne-app's companion items (33-13) can meet the same case.
|
||||
status: open
|
||||
status: resolved (33-14, sm-sitemap-plugin 096b6f6: an additional address a listed record has is left out as `duplicate`, counted on the settings screen)
|
||||
- **Possible fix:** in `classes.Rebuild`, skip an additional address whose absolute URL a content type already lists, or report the overlap on the settings screen.
|
||||
- **Why deferred:** a plugin change in sm-sitemap-plugin, outside 33-12's repositories; the demo works around it in its deploy steps.
|
||||
- **An additional address that the site serves noindex stays in the sitemap (sm-sitemap-plugin, sm-seo-plugin).** The SEO filter judges the CMS's effective SEO only, while `requiresAuth` and `seo.robots` page meta (D-23) live in Nuxt. A page marked that way and also listed as an additional address (the demo's migrated `/subscriptions`) appears in the sitemap with `X-Robots-Tag: noindex, nofollow`. smoke-seo.sh fails on it, and the demo's DEPLOY.md removes the address.
|
||||
status: open
|
||||
status: open, a contract gap (33-14): the sitemap plugin already asks the SEO filter about additional addresses, but the CMS cannot know a noindex that lives only in Nuxt page meta; the route report leaves those pages out, so nothing tells the CMS. Needs the route report to carry the noindex static paths (vue-seo-layer) and the SEO plugin to store and use them (a new field or table): more than a small fix
|
||||
- **Possible fix:** the route report also sends the noindex static paths (as `noindex` entries), and the filter leaves them out of the sitemaps.
|
||||
- **Why deferred:** a contract change across vue-seo-layer and sm-seo-plugin.
|
||||
- **The site answer lists the `journal-tag` pattern while `golem15.journal.tag_pages` is false (sm-seo-plugin).** `classes/site.go` adds every content type's pattern. vue-seo-layer then logs `[vue-seo-layer] drift: the CMS's journal-tag URLs (/blog/tag/:slug) match no Nuxt route` at every start of the demo, although tag paths no longer resolve (TestSEOOutput).
|
||||
status: open
|
||||
status: open, a descriptor gap (33-14): no content descriptor field says that a type resolves no URLs, so sm-seo-plugin cannot tell. Possible fixes: an additive `pact.ContentDescriptor` field (for example `Routable func(ctx) bool`), or sm-journal-plugin registering journal-tag without a DefaultPattern while tag pages are off (the layer already skips an empty pattern)
|
||||
- **Possible fix:** leave out a type whose descriptor resolves no URL (the Journal could report tag pages off on its descriptor).
|
||||
- **Why deferred:** log noise only; a plugin change outside 33-12.
|
||||
- **`Duplicated imports "JournalImageVariant"` on every build of an app with vue-journal-layer (vue-journal-layer).** The type is declared in `app/utils/srcset.ts` and re-exported from `app/utils/types.ts`, both auto-imported.
|
||||
status: open
|
||||
status: resolved (33-14, vue-journal-layer c6ffa6c: types.ts no longer re-exports it; tests/autoImports.test.ts checks every auto-imported name has one home)
|
||||
- **Possible fix:** drop the re-export from `types.ts`, or move the interface to `types.ts` only.
|
||||
- **Why deferred:** a warning only; a layer change, best done before the v0.8.0 tag in 33-14.
|
||||
- **Both DEPLOY.md files call the binary "static" (sm-demo-app, sm-summercmsio-app).** Once the apps require the new sm-journal-plugin, their `CGO_ENABLED=0` release builds are dynamically linked (`file bin/demo`: "dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2"). With `-tags nodynamic` they are static again. Checked in 33-12 on both scratch builds.
|
||||
status: open
|
||||
status: open, decision at the 33-14 release checkpoint (per-app options and a recommendation in the checkpoint report)
|
||||
- **Possible fix:** 33-14 decides per app: add `nodynamic` to `scripts/build.sh` (and summer.yaml `build.tags`), or change the DEPLOY.md table to "dynamically linked (glibc)".
|
||||
- **Why deferred:** the linking decision is 33-14's (33-11).
|
||||
|
||||
@@ -136,7 +136,7 @@ Out-of-scope discoveries logged during execution; not fixed in the plan that fou
|
||||
- **Possible fix:** list a locale's address only when the record has content in it (a translation row, or the default locale), not through fallback.
|
||||
- **Why deferred:** a plugin behaviour change; the site is fixed by its data.
|
||||
- **Saving a registry page through `PUT /_seo/api/v1/pages/{id}` writes `updated_at` in local time (sm-seo-plugin).** In the 33-13 rehearsal (database in UTC, machine in CET), the renamed `/trwaja-prace` got `updated_at` 17:58:54 while the route-reported pages got 16:58:54, so `sitemap-pages-1.xml` and the index carried a lastmod one hour in the future.
|
||||
status: open
|
||||
status: resolved (33-14, sm-seo-plugin dfc85bc: `Page.BeforeSave` stamps both timestamps in UTC for the admin's and the API's saves; the admin create had the same problem). The cause is framework-wide: GORM's default NowFunc is local time, so every model's auto timestamps on a server east of UTC are local (see "From 33-14")
|
||||
- **Possible fix:** write UTC (or a `timestamptz` column) on the page update path, as the route report does.
|
||||
- **Why deferred:** a plugin bug outside 33-13's files; the lastmod corrects itself on the next content change.
|
||||
- **`/` as a registry page loses the home title template (sm-seo-plugin).** Once the route report registers `/` ("Strona główna"), its title comes from the record through `title_template` ("Strona główna | Grzyby Funkcjonalne") instead of `home_template` ("Witamy | %site%"). 33-13's `deploy/seo-pages.csv` sets the home title explicitly.
|
||||
@@ -152,7 +152,7 @@ Out-of-scope discoveries logged during execution; not fixed in the plan that fou
|
||||
- **Possible fix:** declare `seo`, `seo:read`, `sitemap` and `sitemap:read` scopes.
|
||||
- **Why deferred:** a plugin feature.
|
||||
- **`journal:image-variants` cannot run from the generated CLI (framework or sm-journal-plugin).** It stops with `journal: database is not configured`, like `journal:reindex` (which grzyby wraps as `grzyby:reindex`). The one-time backfill of existing featured images that 33-11 names needs a wrapper or a framework fix; new uploads get their variants from the worker in `serve`.
|
||||
status: open
|
||||
status: resolved (33-14, sm-journal-plugin fc3c255: without a published database or bucket the command opens `database.dsn` and `storage.uploads.bucket_url` itself). `journal:reindex`, `journal:fix-headings`, `journal:import-posts` and `journal:export-posts` still use the published database only
|
||||
- **Possible fix:** the generated CLI opens the database for plugin commands, or the app adds `grzyby:image-variants`.
|
||||
- **Why deferred:** decided with 33-14's release steps.
|
||||
- **The Journal's seeded `uncategorized` category is listed in `sitemap-categories-1.xml` (sm-grzybyfunkcjonalne-app data).** It has no posts and the PHP sitemap never listed it (7 categories), but `/uncategorized` answers 200 and the per-type file lists every published category.
|
||||
@@ -171,3 +171,12 @@ Out-of-scope discoveries logged during execution; not fixed in the plan that fou
|
||||
- the demo's urls and smoke expectations moved to the `/pl/…` addresses;
|
||||
- run `smoke-seo.sh` with `SMOKE_SEO_STRICT=1` so the WARN lines fail.
|
||||
- **Notice:** every Polish URL of the demo changes; the cookie-at-the-same-address behaviour of D-13 is gone.
|
||||
|
||||
## From 33-14 (release preparation)
|
||||
|
||||
- **GORM stamps `created_at` and `updated_at` in the process's local time (framework, lagoon).** No module sets `gorm.Config.NowFunc`, so GORM's default (`time.Now().Local()`) fills every model's auto timestamps; a `timestamp` column keeps the wall clock, so on a machine east of UTC every auto-stamped row is ahead by the offset. The registry page path is fixed in sm-seo-plugin (dfc85bc); auto-links, rules, entries and every other plugin's models are not. The servers run in UTC, so production is not affected today.
|
||||
status: open
|
||||
- **Possible fix:** `lagoon.Open` sets `NowFunc: func() time.Time { return time.Now().UTC() }` (a behaviour change for non-UTC hosts; needs a decision), or each plugin stamps UTC itself.
|
||||
- **Why deferred:** framework-wide behaviour, not one of the six fixes.
|
||||
- **The route report sent the i18n copies of every page as pages of their own (vue-seo-layer).** Found by the demo locale rehearsal: with `prefix_except_default` the report sent `/pl`, `/pl/contact`, … next to `/`, `/contact`, and the registry made seven untranslated pages of them.
|
||||
status: resolved (33-14, vue-seo-layer b3a0078: routes named with another locale's suffix are left out; their translations are set per page in SEO › Pages). A follow-up could report the locale paths so the plugin fills each page's addresses itself (a contract change).
|
||||
|
||||
Reference in New Issue
Block a user