From 07e9458aaf2796f5f48a69302acd26914032b879 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Fri, 31 Jul 2026 19:03:07 +0200 Subject: [PATCH] fix(seo): correct sitemap routes --- next-sitemap.config.cjs | 9 +- .../(sitemaps)/pages-sitemap.xml/route.ts | 27 +-- .../(sitemaps)/posts-sitemap.xml/route.ts | 71 +++--- src/collections/Events.ts | 4 + .../Events/hooks/revalidateEvent.ts | 46 ++++ src/collections/Pages/hooks/revalidatePage.ts | 12 +- src/collections/Posts/hooks/revalidatePost.ts | 6 +- src/collections/Preistraeger.ts | 7 + .../hooks/revalidatePreistraeger.ts | 46 ++++ src/utilities/publicationQuery.ts | 15 ++ src/utilities/sitemapBaseURL.cjs | 58 +++++ src/utilities/sitemapRoutes.ts | 65 ++++++ tests/int/sitemap-routes.int.spec.ts | 210 ++++++++++++++++++ 13 files changed, 515 insertions(+), 61 deletions(-) create mode 100644 src/collections/Events/hooks/revalidateEvent.ts create mode 100644 src/collections/Preistraeger/hooks/revalidatePreistraeger.ts create mode 100644 src/utilities/sitemapBaseURL.cjs create mode 100644 src/utilities/sitemapRoutes.ts create mode 100644 tests/int/sitemap-routes.int.spec.ts diff --git a/next-sitemap.config.cjs b/next-sitemap.config.cjs index 689f088..2b63de0 100644 --- a/next-sitemap.config.cjs +++ b/next-sitemap.config.cjs @@ -1,13 +1,12 @@ -const SITE_URL = - process.env.NEXT_PUBLIC_SERVER_URL || - process.env.VERCEL_PROJECT_PRODUCTION_URL || - 'https://example.com' +const { getSitemapBaseURL } = require('./src/utilities/sitemapBaseURL.cjs') + +const SITE_URL = getSitemapBaseURL({ ...process.env, NODE_ENV: 'production' }) /** @type {import('next-sitemap').IConfig} */ module.exports = { siteUrl: SITE_URL, generateRobotsTxt: true, - exclude: ['/posts-sitemap.xml', '/pages-sitemap.xml', '/*', '/posts/*'], + exclude: ['/posts-sitemap.xml', '/pages-sitemap.xml', '/*'], robotsTxtOptions: { policies: [ { diff --git a/src/app/(frontend)/(sitemaps)/pages-sitemap.xml/route.ts b/src/app/(frontend)/(sitemaps)/pages-sitemap.xml/route.ts index f7b9652..1e47856 100644 --- a/src/app/(frontend)/(sitemaps)/pages-sitemap.xml/route.ts +++ b/src/app/(frontend)/(sitemaps)/pages-sitemap.xml/route.ts @@ -3,28 +3,22 @@ import { getPayload } from 'payload' import config from '@payload-config' import { unstable_cache } from 'next/cache' +import { getPublicPublishedQueryOptions } from '@/utilities/publicationQuery' +import { createSitemapEntry, pageSitemapPath, type SitemapEntry } from '@/utilities/sitemapRoutes' + const getPagesSitemap = unstable_cache( async () => { const payload = await getPayload({ config }) - const SITE_URL = - process.env.NEXT_PUBLIC_SERVER_URL || - process.env.VERCEL_PROJECT_PRODUCTION_URL || - 'https://example.com' const results = await payload.find({ collection: 'pages', - overrideAccess: false, - draft: false, + ...getPublicPublishedQueryOptions(), depth: 0, limit: 1000, pagination: false, - where: { - _status: { - equals: 'published', - }, - }, select: { slug: true, + spaPath: true, updatedAt: true, }, }) @@ -32,15 +26,8 @@ const getPagesSitemap = unstable_cache( const dateFallback = new Date().toISOString() const sitemap = results.docs - ? results.docs - .filter((page) => Boolean(page?.slug)) - .map((page) => { - return { - loc: page?.slug === 'home' ? `${SITE_URL}/` : `${SITE_URL}/${page?.slug}`, - lastmod: page.updatedAt || dateFallback, - } - }) - : [] + .map((page) => createSitemapEntry(page, pageSitemapPath(page), dateFallback)) + .filter((entry): entry is SitemapEntry => Boolean(entry)) return sitemap }, diff --git a/src/app/(frontend)/(sitemaps)/posts-sitemap.xml/route.ts b/src/app/(frontend)/(sitemaps)/posts-sitemap.xml/route.ts index 0716abb..095c9b5 100644 --- a/src/app/(frontend)/(sitemaps)/posts-sitemap.xml/route.ts +++ b/src/app/(frontend)/(sitemaps)/posts-sitemap.xml/route.ts @@ -3,44 +3,55 @@ import { getPayload } from 'payload' import config from '@payload-config' import { unstable_cache } from 'next/cache' +import { getPublicPublishedQueryOptions } from '@/utilities/publicationQuery' +import { createSitemapEntry, detailSitemapPath, type SitemapEntry } from '@/utilities/sitemapRoutes' + +const sitemapCollections = [ + { collection: 'posts', prefix: '/presse/blog' }, + { collection: 'events', prefix: '/presse/events' }, + { collection: 'preistraeger', prefix: '/preistraeger' }, +] as const + const getPostsSitemap = unstable_cache( async () => { const payload = await getPayload({ config }) - const SITE_URL = - process.env.NEXT_PUBLIC_SERVER_URL || - process.env.VERCEL_PROJECT_PRODUCTION_URL || - 'https://example.com' - const results = await payload.find({ - collection: 'posts', - overrideAccess: false, - draft: false, - depth: 0, - limit: 1000, - pagination: false, - where: { - _status: { - equals: 'published', - }, - }, - select: { - slug: true, - updatedAt: true, - }, - }) + const results = await Promise.all( + sitemapCollections.map(({ collection }) => + payload.find({ + collection, + ...getPublicPublishedQueryOptions(), + depth: 0, + limit: 1000, + pagination: false, + select: { + slug: true, + spaPath: true, + updatedAt: true, + }, + }), + ), + ) const dateFallback = new Date().toISOString() - const sitemap = results.docs - ? results.docs - .filter((post) => Boolean(post?.slug)) - .map((post) => ({ - loc: `${SITE_URL}/posts/${post?.slug}`, - lastmod: post.updatedAt || dateFallback, - })) - : [] + return results.flatMap((result, index) => { + const { prefix } = sitemapCollections[index] - return sitemap + return result.docs.reduce((entries, document) => { + const entry = createSitemapEntry( + document, + detailSitemapPath(document, prefix), + dateFallback, + ) + + if (entry) { + entries.push(entry) + } + + return entries + }, []) + }) }, ['posts-sitemap'], { diff --git a/src/collections/Events.ts b/src/collections/Events.ts index 285b28f..c1b13c1 100644 --- a/src/collections/Events.ts +++ b/src/collections/Events.ts @@ -7,6 +7,8 @@ import { eventDetailContent } from '@/spa/eventDetailContent' import { generatePreviewPath } from '@/utilities/generatePreviewPath' import { slugField } from 'payload' +import { revalidateEvent, revalidateEventDelete } from './Events/hooks/revalidateEvent' + import { MetaDescriptionField, MetaTitleField, @@ -273,6 +275,8 @@ export const Events: CollectionConfig = { slugField(), ], hooks: { + afterChange: [revalidateEvent], + afterDelete: [revalidateEventDelete], beforeChange: [populatePublishedAt], }, versions: { diff --git a/src/collections/Events/hooks/revalidateEvent.ts b/src/collections/Events/hooks/revalidateEvent.ts new file mode 100644 index 0000000..5c3145a --- /dev/null +++ b/src/collections/Events/hooks/revalidateEvent.ts @@ -0,0 +1,46 @@ +import type { CollectionAfterChangeHook, CollectionAfterDeleteHook } from 'payload' + +import { revalidatePath, revalidateTag } from 'next/cache' + +import type { Event } from '../../../payload-types' + +const eventPath = (doc: Pick) => + doc.spaPath || `/presse/events/${doc.slug}` + +export const revalidateEvent: CollectionAfterChangeHook = ({ + doc, + previousDoc, + req: { payload, context }, +}) => { + if (!context.disableRevalidate) { + if (doc._status === 'published') { + const path = eventPath(doc) + + payload.logger.info(`Revalidating event at path: ${path}`) + revalidatePath(path) + revalidateTag('posts-sitemap', 'max') + } + + if (previousDoc?._status === 'published' && doc._status !== 'published') { + const oldPath = eventPath(previousDoc) + + payload.logger.info(`Revalidating old event at path: ${oldPath}`) + revalidatePath(oldPath) + revalidateTag('posts-sitemap', 'max') + } + } + + return doc +} + +export const revalidateEventDelete: CollectionAfterDeleteHook = ({ + doc, + req: { context }, +}) => { + if (!context.disableRevalidate) { + revalidatePath(eventPath(doc)) + revalidateTag('posts-sitemap', 'max') + } + + return doc +} diff --git a/src/collections/Pages/hooks/revalidatePage.ts b/src/collections/Pages/hooks/revalidatePage.ts index 29b92bf..167629e 100644 --- a/src/collections/Pages/hooks/revalidatePage.ts +++ b/src/collections/Pages/hooks/revalidatePage.ts @@ -11,7 +11,8 @@ export const revalidatePage: CollectionAfterChangeHook = ({ }) => { if (!context.disableRevalidate) { if (doc._status === 'published') { - const path = doc.slug === 'home' ? '/' : `/${doc.slug}` + const path = + doc.spaPath || (doc.slug === 'home' || doc.slug === 'startseite' ? '/' : `/${doc.slug}`) payload.logger.info(`Revalidating page at path: ${path}`) @@ -21,7 +22,11 @@ export const revalidatePage: CollectionAfterChangeHook = ({ // If the page was previously published, we need to revalidate the old path if (previousDoc?._status === 'published' && doc._status !== 'published') { - const oldPath = previousDoc.slug === 'home' ? '/' : `/${previousDoc.slug}` + const oldPath = + previousDoc.spaPath || + (previousDoc.slug === 'home' || previousDoc.slug === 'startseite' + ? '/' + : `/${previousDoc.slug}`) payload.logger.info(`Revalidating old page at path: ${oldPath}`) @@ -34,7 +39,8 @@ export const revalidatePage: CollectionAfterChangeHook = ({ export const revalidateDelete: CollectionAfterDeleteHook = ({ doc, req: { context } }) => { if (!context.disableRevalidate) { - const path = doc?.slug === 'home' ? '/' : `/${doc?.slug}` + const path = + doc?.spaPath || (doc?.slug === 'home' || doc?.slug === 'startseite' ? '/' : `/${doc?.slug}`) revalidatePath(path) revalidateTag('pages-sitemap', 'max') } diff --git a/src/collections/Posts/hooks/revalidatePost.ts b/src/collections/Posts/hooks/revalidatePost.ts index cb4a52d..ccf7f3b 100644 --- a/src/collections/Posts/hooks/revalidatePost.ts +++ b/src/collections/Posts/hooks/revalidatePost.ts @@ -11,7 +11,7 @@ export const revalidatePost: CollectionAfterChangeHook = ({ }) => { if (!context.disableRevalidate) { if (doc._status === 'published') { - const path = doc.spaPath || `/posts/${doc.slug}` + const path = doc.spaPath || `/presse/blog/${doc.slug}` payload.logger.info(`Revalidating post at path: ${path}`) @@ -21,7 +21,7 @@ export const revalidatePost: CollectionAfterChangeHook = ({ // If the post was previously published, we need to revalidate the old path if (previousDoc._status === 'published' && doc._status !== 'published') { - const oldPath = previousDoc.spaPath || `/posts/${previousDoc.slug}` + const oldPath = previousDoc.spaPath || `/presse/blog/${previousDoc.slug}` payload.logger.info(`Revalidating old post at path: ${oldPath}`) @@ -34,7 +34,7 @@ export const revalidatePost: CollectionAfterChangeHook = ({ export const revalidateDelete: CollectionAfterDeleteHook = ({ doc, req: { context } }) => { if (!context.disableRevalidate) { - const path = doc?.spaPath || `/posts/${doc?.slug}` + const path = doc?.spaPath || `/presse/blog/${doc?.slug}` revalidatePath(path) revalidateTag('posts-sitemap', 'max') diff --git a/src/collections/Preistraeger.ts b/src/collections/Preistraeger.ts index 70fec09..bd663d5 100644 --- a/src/collections/Preistraeger.ts +++ b/src/collections/Preistraeger.ts @@ -7,6 +7,11 @@ import { preistraegerDetailContent } from '@/spa/preistraegerDetailContent' import { generatePreviewPath } from '@/utilities/generatePreviewPath' import { slugField } from 'payload' +import { + revalidatePreistraeger, + revalidatePreistraegerDelete, +} from './Preistraeger/hooks/revalidatePreistraeger' + import { MetaDescriptionField, MetaTitleField, @@ -372,6 +377,8 @@ export const Preistraeger: CollectionConfig = { slugField(), ], hooks: { + afterChange: [revalidatePreistraeger], + afterDelete: [revalidatePreistraegerDelete], beforeChange: [populatePublishedAt], }, versions: { diff --git a/src/collections/Preistraeger/hooks/revalidatePreistraeger.ts b/src/collections/Preistraeger/hooks/revalidatePreistraeger.ts new file mode 100644 index 0000000..cfcb8c4 --- /dev/null +++ b/src/collections/Preistraeger/hooks/revalidatePreistraeger.ts @@ -0,0 +1,46 @@ +import type { CollectionAfterChangeHook, CollectionAfterDeleteHook } from 'payload' + +import { revalidatePath, revalidateTag } from 'next/cache' + +import type { Preistraeger } from '../../../payload-types' + +const preistraegerPath = (doc: Pick) => + doc.spaPath || `/preistraeger/${doc.slug}` + +export const revalidatePreistraeger: CollectionAfterChangeHook = ({ + doc, + previousDoc, + req: { payload, context }, +}) => { + if (!context.disableRevalidate) { + if (doc._status === 'published') { + const path = preistraegerPath(doc) + + payload.logger.info(`Revalidating Preisträger at path: ${path}`) + revalidatePath(path) + revalidateTag('posts-sitemap', 'max') + } + + if (previousDoc?._status === 'published' && doc._status !== 'published') { + const oldPath = preistraegerPath(previousDoc) + + payload.logger.info(`Revalidating old Preisträger at path: ${oldPath}`) + revalidatePath(oldPath) + revalidateTag('posts-sitemap', 'max') + } + } + + return doc +} + +export const revalidatePreistraegerDelete: CollectionAfterDeleteHook = ({ + doc, + req: { context }, +}) => { + if (!context.disableRevalidate) { + revalidatePath(preistraegerPath(doc)) + revalidateTag('posts-sitemap', 'max') + } + + return doc +} diff --git a/src/utilities/publicationQuery.ts b/src/utilities/publicationQuery.ts index 38c4407..d0cfe93 100644 --- a/src/utilities/publicationQuery.ts +++ b/src/utilities/publicationQuery.ts @@ -20,3 +20,18 @@ const previewPublicationQuery = Object.freeze({ export function getPublicationQueryOptions(isPreview: boolean): PublicationQueryOptions { return isPreview ? previewPublicationQuery : publicPublicationQuery } + +/** + * Adds an explicit status predicate for public indexes such as sitemaps while + * retaining the same anonymous access and draft behavior as public routes. + */ +export function getPublicPublishedQueryOptions() { + return { + ...getPublicationQueryOptions(false), + where: { + _status: { + equals: 'published' as const, + }, + }, + } +} diff --git a/src/utilities/sitemapBaseURL.cjs b/src/utilities/sitemapBaseURL.cjs new file mode 100644 index 0000000..f1c95f9 --- /dev/null +++ b/src/utilities/sitemapBaseURL.cjs @@ -0,0 +1,58 @@ +const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]']) + +/** + * Normalize an explicitly configured public URL to a bare origin. Bare deployment + * hostnames (such as Vercel's production URL) are treated as HTTPS. + * + * @param {string} value + * @param {{ production?: boolean }} [options] + */ +function normalizeSitemapBaseURL( + value, + { production = process.env.NODE_ENV === 'production' } = {}, +) { + const configuredURL = typeof value === 'string' ? value.trim() : '' + + if (!configuredURL) { + throw new Error( + 'Sitemap base URL is not configured. Set NEXT_PUBLIC_SERVER_URL or VERCEL_PROJECT_PRODUCTION_URL.', + ) + } + + const url = new URL( + /^[a-z][a-z\d+.-]*:\/\//i.test(configuredURL) ? configuredURL : `https://${configuredURL}`, + ) + + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new Error(`Sitemap base URL must use HTTP or HTTPS, received ${url.protocol}`) + } + + const isPlaceholderHostname = + url.hostname === 'example.com' || url.hostname.endsWith('.example.com') + + if ( + production && + (url.protocol !== 'https:' || LOCAL_HOSTNAMES.has(url.hostname) || isPlaceholderHostname) + ) { + throw new Error('Production sitemap base URL must be a public HTTPS origin.') + } + + return url.origin +} + +/** + * @param {NodeJS.ProcessEnv | Record} [environment] + */ +function getSitemapBaseURL(environment = process.env) { + const configuredURL = + environment.NEXT_PUBLIC_SERVER_URL || environment.VERCEL_PROJECT_PRODUCTION_URL + + return normalizeSitemapBaseURL(configuredURL || '', { + production: environment.NODE_ENV === 'production', + }) +} + +module.exports = { + getSitemapBaseURL, + normalizeSitemapBaseURL, +} diff --git a/src/utilities/sitemapRoutes.ts b/src/utilities/sitemapRoutes.ts new file mode 100644 index 0000000..5db85ba --- /dev/null +++ b/src/utilities/sitemapRoutes.ts @@ -0,0 +1,65 @@ +import { getSitemapBaseURL } from './sitemapBaseURL.cjs' + +type SitemapDocument = { + slug?: string | null + spaPath?: string | null + updatedAt?: string | null +} + +export type SitemapEntry = { + loc: string + lastmod: string +} + +function normalizeStoredPath(value: unknown, expectedPrefix?: string): string | undefined { + if (typeof value !== 'string') return undefined + + const path = value.trim() + if (!path.startsWith('/') || path.startsWith('//')) return undefined + + const pathname = new URL(path, 'https://sitemap.invalid').pathname.replace(/\/+$/, '') || '/' + if (expectedPrefix && !pathname.startsWith(`${expectedPrefix}/`)) return undefined + + return pathname +} + +function normalizeSlug(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined + + const slug = value.trim().replace(/^\/+|\/+$/g, '') + return slug || undefined +} + +export function pageSitemapPath(page: SitemapDocument): string | undefined { + const storedPath = normalizeStoredPath(page.spaPath) + if (storedPath) return storedPath + + const slug = normalizeSlug(page.slug) + if (!slug) return undefined + + return slug === 'home' || slug === 'startseite' ? '/' : `/${slug}` +} + +export function detailSitemapPath( + document: SitemapDocument, + prefix: '/presse/blog' | '/presse/events' | '/preistraeger', +): string | undefined { + const storedPath = normalizeStoredPath(document.spaPath, prefix) + if (storedPath) return storedPath + + const slug = normalizeSlug(document.slug) + return slug ? `${prefix}/${slug}` : undefined +} + +export function createSitemapEntry( + document: SitemapDocument, + path: string | undefined, + dateFallback: string, +): SitemapEntry | undefined { + if (!path) return undefined + + return { + loc: `${getSitemapBaseURL()}${path}`, + lastmod: document.updatedAt || dateFallback, + } +} diff --git a/tests/int/sitemap-routes.int.spec.ts b/tests/int/sitemap-routes.int.spec.ts new file mode 100644 index 0000000..5b962ca --- /dev/null +++ b/tests/int/sitemap-routes.int.spec.ts @@ -0,0 +1,210 @@ +import { createRequire } from 'node:module' + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { getSitemapBaseURL, normalizeSitemapBaseURL } from '@/utilities/sitemapBaseURL.cjs' + +type SitemapCollection = 'pages' | 'posts' | 'events' | 'preistraeger' + +type SitemapDocument = { + _status: 'draft' | 'published' + slug?: string + spaPath?: string + updatedAt?: string +} + +type SitemapFindQuery = { + collection: SitemapCollection + draft?: boolean + overrideAccess?: boolean + where?: { + _status?: { + equals?: string + } + } +} + +const routeState = vi.hoisted(() => ({ + docs: { + events: [], + pages: [], + posts: [], + preistraeger: [], + } as Record, + queries: [] as SitemapFindQuery[], +})) + +vi.mock('@payload-config', () => ({ default: {} })) + +vi.mock('next/cache', () => ({ + unstable_cache: (callback: () => unknown) => callback, +})) + +vi.mock('payload', () => ({ + getPayload: async () => ({ + find: async (query: SitemapFindQuery) => { + routeState.queries.push(query) + + const isPublishedPublicQuery = + query.draft === false && + query.overrideAccess === false && + query.where?._status?.equals === 'published' + + return { + docs: isPublishedPublicQuery + ? routeState.docs[query.collection].filter((document) => document._status === 'published') + : routeState.docs[query.collection], + } + }, + }), +})) + +import { GET as getPagesSitemap } from '@/app/(frontend)/(sitemaps)/pages-sitemap.xml/route' +import { GET as getPostsSitemap } from '@/app/(frontend)/(sitemaps)/posts-sitemap.xml/route' + +const productionURL = 'https://www.bayerischer-mittelstandspreis.de' + +async function sitemapLocations(response: Response): Promise { + const xml = await response.text() + return [...xml.matchAll(/([^<]+)<\/loc>/g)].map((match) => match[1]) +} + +describe('sitemap route generation', () => { + beforeEach(() => { + process.env.NEXT_PUBLIC_SERVER_URL = `${productionURL}/` + process.env.VERCEL_PROJECT_PRODUCTION_URL = '' + + for (const collection of Object.keys(routeState.docs) as SitemapCollection[]) { + routeState.docs[collection] = [] + } + routeState.queries = [] + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it.each([ + { + label: 'stored homepage path', + document: { _status: 'published', slug: 'startseite', spaPath: '/' }, + expectedPath: '/', + }, + { + label: 'home slug fallback', + document: { _status: 'published', slug: 'home' }, + expectedPath: '/', + }, + { + label: 'startseite slug fallback', + document: { _status: 'published', slug: 'startseite' }, + expectedPath: '/', + }, + { + label: 'normal stored page path', + document: { _status: 'published', slug: 'legacy-contact', spaPath: '/kontakt' }, + expectedPath: '/kontakt', + }, + ] as const)('emits the $label', async ({ document, expectedPath }) => { + routeState.docs.pages = [document] + + await expect(sitemapLocations(await getPagesSitemap())).resolves.toEqual([ + `${productionURL}${expectedPath}`, + ]) + }) + + it.each([ + { collection: 'posts', prefix: '/presse/blog', slug: 'presseartikel' }, + { collection: 'events', prefix: '/presse/events', slug: 'preisverleihung' }, + { collection: 'preistraeger', prefix: '/preistraeger', slug: 'muster-gmbh' }, + ] as const)( + 'emits $collection detail routes under $prefix', + async ({ collection, prefix, slug }) => { + routeState.docs[collection] = [ + { + _status: 'published', + slug, + spaPath: `${prefix}/${slug}`, + }, + ] + + await expect(sitemapLocations(await getPostsSitemap())).resolves.toContain( + `${productionURL}${prefix}/${slug}`, + ) + }, + ) + + it('excludes unpublished documents through the established anonymous public query contract', async () => { + routeState.docs.pages = [ + { _status: 'published', slug: 'sichtbar', spaPath: '/sichtbar' }, + { _status: 'draft', slug: 'entwurf', spaPath: '/entwurf' }, + ] + routeState.docs.posts = [ + { _status: 'published', slug: 'sichtbar', spaPath: '/presse/blog/sichtbar' }, + { _status: 'draft', slug: 'entwurf', spaPath: '/presse/blog/entwurf' }, + ] + + const locations = [ + ...(await sitemapLocations(await getPagesSitemap())), + ...(await sitemapLocations(await getPostsSitemap())), + ] + + expect(locations).toContain(`${productionURL}/sichtbar`) + expect(locations).toContain(`${productionURL}/presse/blog/sichtbar`) + expect(locations.some((location) => location.includes('entwurf'))).toBe(false) + expect(routeState.queries).toHaveLength(4) + expect(routeState.queries).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + draft: false, + overrideAccess: false, + where: { _status: { equals: 'published' } }, + }), + ]), + ) + }) +}) + +describe('sitemap production base URL', () => { + it.each([ + [ + 'trailing path and slash', + 'https://www.bayerischer-mittelstandspreis.de/path///', + productionURL, + ], + ['bare deployment hostname', 'bmp-production.vercel.app', 'https://bmp-production.vercel.app'], + ])('normalizes a %s', (_label, configuredURL, expectedURL) => { + expect(normalizeSitemapBaseURL(configuredURL)).toBe(expectedURL) + }) + + it('rejects a localhost production origin', () => { + expect(() => normalizeSitemapBaseURL('http://localhost:3000', { production: true })).toThrow( + 'Production sitemap base URL must be a public HTTPS origin.', + ) + }) + + it('fails instead of emitting a fallback when no public URL is configured', () => { + expect(() => getSitemapBaseURL({ NODE_ENV: 'production' })).toThrow( + 'Sitemap base URL is not configured.', + ) + }) + + it('uses the normalized production origin for the robots sitemap URLs', () => { + const require = createRequire(import.meta.url) + const configPath = require.resolve('../../next-sitemap.config.cjs') + + delete require.cache[configPath] + process.env.NEXT_PUBLIC_SERVER_URL = `${productionURL}/deployment/path/` + + const config = require(configPath) as { + robotsTxtOptions: { additionalSitemaps: string[] } + siteUrl: string + } + + expect(config.siteUrl).toBe(productionURL) + expect(config.robotsTxtOptions.additionalSitemaps).toEqual([ + `${productionURL}/pages-sitemap.xml`, + `${productionURL}/posts-sitemap.xml`, + ]) + }) +})