fix: HTML sitemap page 1 redirects with the locale's segment

- seoFirstPageRedirect answers the unnumbered URL or null when the target
  is the URL itself, so /mapa-strony/<group>/strona/1 no longer loops (L4 WR-01)
- SeoHtmlSitemapPage takes the segment from the site answer, else the build
  option, else the param, and redirects only when the helper answers a target
This commit is contained in:
Jakub Zych
2026-11-04 22:41:18 +01:00
parent 7a26692001
commit 7588aa53b0
3 changed files with 35 additions and 5 deletions

View File

@@ -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<string, string> } | 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<string, string> } } | 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<SeoSitemapOverview | SeoSitemapGroupPage>('/api/__seo__/sitemap', {

View File

@@ -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).

View File

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