diff --git a/app/components/global/SeoHtmlSitemapPage.vue b/app/components/global/SeoHtmlSitemapPage.vue
index 7cca349..96990a9 100644
--- a/app/components/global/SeoHtmlSitemapPage.vue
+++ b/app/components/global/SeoHtmlSitemapPage.vue
@@ -11,7 +11,7 @@
// Registered globally under the stable name SeoHtmlSitemapPage: an app
// overrides it by registering its own component of that name.
import { formatSeoLabel } from '../../utils/labels.ts'
-import { seoPageHref, seoPageNumber } from '../../utils/pageHref.ts'
+import { seoFirstPageRedirect, seoPageNumber } from '../../utils/pageHref.ts'
import { seoSitePath } from '../../utils/sitePath.ts'
import type { SeoBreadcrumb, SeoSitemapGroupPage, SeoSitemapOverview } from '../../utils/types.ts'
@@ -25,16 +25,21 @@ const labels = useSeoLabels(() => props.locale)
const own = (config.public.seo as { htmlSitemap?: { paths?: Record } | false } | undefined)?.htmlSitemap
const basePath = (own && typeof own === 'object' ? own.paths?.[props.locale] : '') || route.path
-// The page number of a group page, in the site's pagination form.
-const form = site.value?.pagination?.form === 'path' ? 'path' : 'query'
+// The page number of a group page: from the path-form route's :page, else
+// from the query parameter.
const param = site.value?.pagination?.param || 'page'
+const ownPagination = (config.public.seo as { pagination?: { segments?: Record } } | undefined)?.pagination
+// The locale's path segment, as the path-form route was registered (L4 WR-01).
+const segment = site.value?.pagination?.segments?.[props.locale] || ownPagination?.segments?.[props.locale] || param
let page = 1
if (props.group) {
const parsed = props.pathPage ? (/^\d{1,6}$/.test(props.pathPage) ? Number(props.pathPage) : null) : seoPageNumber(route.fullPath, 'query', '', param)
if (parsed === null || parsed < 1) throw createError({ statusCode: 404, statusMessage: 'Page Not Found', fatal: true })
page = parsed
const explicitFirst = props.pathPage === '1' || route.query[param] === '1'
- if (explicitFirst) await navigateTo(seoPageHref(route.fullPath, 1, form, '', param), { redirectCode: 301 })
+ // A 301 to the unnumbered URL, never to the page's own URL.
+ const target = explicitFirst ? seoFirstPageRedirect(route.fullPath, props.pathPage ? 'path' : 'query', segment, param) : null
+ if (target) await navigateTo(target, { redirectCode: 301 })
}
const { data, error } = await useFetch('/api/__seo__/sitemap', {
diff --git a/app/utils/pageHref.ts b/app/utils/pageHref.ts
index f061223..f23a27c 100644
--- a/app/utils/pageHref.ts
+++ b/app/utils/pageHref.ts
@@ -29,6 +29,17 @@ export function seoPageHref(url: string, page: number, form: SeoPaginationForm =
return qs ? `${path}?${qs}` : path
}
+/**
+ * Where an explicit page 1 (/mapa-strony/posty/strona/1, /blog?page=1)
+ * redirects: the unnumbered URL, built with the locale's segment, or null
+ * when that target is the URL itself (a segment the URL does not carry), so
+ * a page never answers a 301 to its own address (L4 WR-01).
+ */
+export function seoFirstPageRedirect(fullPath: string, form: SeoPaginationForm, segment = '', param = 'page'): string | null {
+ const target = seoPageHref(fullPath, 1, form, segment, param)
+ return target === fullPath ? null : target
+}
+
/**
* The page number a URL carries in the given form: 1 without one, the
* number for a positive integer, null for anything else (a 404).
diff --git a/tests/pageHref.test.ts b/tests/pageHref.test.ts
index a9e96a6..c6cd294 100644
--- a/tests/pageHref.test.ts
+++ b/tests/pageHref.test.ts
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict'
import test from 'node:test'
-import { seoPageHref, seoPageNumber } from '../app/utils/pageHref.ts'
+import { seoFirstPageRedirect, seoPageHref, seoPageNumber } from '../app/utils/pageHref.ts'
import { seoFillPages } from '../app/utils/seo.ts'
test('seoPageHref: the UI-SPEC L2 examples', () => {
@@ -45,3 +45,17 @@ test('seoFillPages: %pages% in the title and meta, only once the count is known'
assert.equal(seoFillPages(head, null), head)
assert.equal(seoFillPages(head, 0), head)
})
+
+test('seoFirstPageRedirect: page 1 goes to the unnumbered URL with the locale segment (L4 WR-01)', () => {
+ assert.equal(seoFirstPageRedirect('/mapa-strony/posty/strona/1', 'path', 'strona', 'page'), '/mapa-strony/posty')
+ assert.equal(seoFirstPageRedirect('/sitemap/posts/page/1', 'path', 'page', 'page'), '/sitemap/posts')
+ assert.equal(seoFirstPageRedirect('/mapa-strony/posty?page=1', 'query', '', 'page'), '/mapa-strony/posty')
+ assert.equal(seoFirstPageRedirect('/mapa-strony/posty/strona/1?x=1', 'path', 'strona', 'page'), '/mapa-strony/posty?x=1')
+})
+
+test('seoFirstPageRedirect: never a redirect to the URL itself (no 301 loop)', () => {
+ // the segment missing: seoPageHref falls back to the param, which the URL does not carry
+ assert.equal(seoFirstPageRedirect('/mapa-strony/posty/strona/1', 'path', '', 'page'), null)
+ assert.equal(seoFirstPageRedirect('/mapa-strony/posty', 'query', '', 'page'), null)
+ assert.equal(seoFirstPageRedirect('/sitemap/posts', 'path', 'page', 'page'), null)
+})