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) +})