fix(seo): correct sitemap routes
This commit is contained in:
@@ -1,13 +1,12 @@
|
|||||||
const SITE_URL =
|
const { getSitemapBaseURL } = require('./src/utilities/sitemapBaseURL.cjs')
|
||||||
process.env.NEXT_PUBLIC_SERVER_URL ||
|
|
||||||
process.env.VERCEL_PROJECT_PRODUCTION_URL ||
|
const SITE_URL = getSitemapBaseURL({ ...process.env, NODE_ENV: 'production' })
|
||||||
'https://example.com'
|
|
||||||
|
|
||||||
/** @type {import('next-sitemap').IConfig} */
|
/** @type {import('next-sitemap').IConfig} */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
siteUrl: SITE_URL,
|
siteUrl: SITE_URL,
|
||||||
generateRobotsTxt: true,
|
generateRobotsTxt: true,
|
||||||
exclude: ['/posts-sitemap.xml', '/pages-sitemap.xml', '/*', '/posts/*'],
|
exclude: ['/posts-sitemap.xml', '/pages-sitemap.xml', '/*'],
|
||||||
robotsTxtOptions: {
|
robotsTxtOptions: {
|
||||||
policies: [
|
policies: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,28 +3,22 @@ import { getPayload } from 'payload'
|
|||||||
import config from '@payload-config'
|
import config from '@payload-config'
|
||||||
import { unstable_cache } from 'next/cache'
|
import { unstable_cache } from 'next/cache'
|
||||||
|
|
||||||
|
import { getPublicPublishedQueryOptions } from '@/utilities/publicationQuery'
|
||||||
|
import { createSitemapEntry, pageSitemapPath, type SitemapEntry } from '@/utilities/sitemapRoutes'
|
||||||
|
|
||||||
const getPagesSitemap = unstable_cache(
|
const getPagesSitemap = unstable_cache(
|
||||||
async () => {
|
async () => {
|
||||||
const payload = await getPayload({ config })
|
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({
|
const results = await payload.find({
|
||||||
collection: 'pages',
|
collection: 'pages',
|
||||||
overrideAccess: false,
|
...getPublicPublishedQueryOptions(),
|
||||||
draft: false,
|
|
||||||
depth: 0,
|
depth: 0,
|
||||||
limit: 1000,
|
limit: 1000,
|
||||||
pagination: false,
|
pagination: false,
|
||||||
where: {
|
|
||||||
_status: {
|
|
||||||
equals: 'published',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
select: {
|
select: {
|
||||||
slug: true,
|
slug: true,
|
||||||
|
spaPath: true,
|
||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -32,15 +26,8 @@ const getPagesSitemap = unstable_cache(
|
|||||||
const dateFallback = new Date().toISOString()
|
const dateFallback = new Date().toISOString()
|
||||||
|
|
||||||
const sitemap = results.docs
|
const sitemap = results.docs
|
||||||
? results.docs
|
.map((page) => createSitemapEntry(page, pageSitemapPath(page), dateFallback))
|
||||||
.filter((page) => Boolean(page?.slug))
|
.filter((entry): entry is SitemapEntry => Boolean(entry))
|
||||||
.map((page) => {
|
|
||||||
return {
|
|
||||||
loc: page?.slug === 'home' ? `${SITE_URL}/` : `${SITE_URL}/${page?.slug}`,
|
|
||||||
lastmod: page.updatedAt || dateFallback,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
: []
|
|
||||||
|
|
||||||
return sitemap
|
return sitemap
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,44 +3,55 @@ import { getPayload } from 'payload'
|
|||||||
import config from '@payload-config'
|
import config from '@payload-config'
|
||||||
import { unstable_cache } from 'next/cache'
|
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(
|
const getPostsSitemap = unstable_cache(
|
||||||
async () => {
|
async () => {
|
||||||
const payload = await getPayload({ config })
|
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({
|
const results = await Promise.all(
|
||||||
collection: 'posts',
|
sitemapCollections.map(({ collection }) =>
|
||||||
overrideAccess: false,
|
payload.find({
|
||||||
draft: false,
|
collection,
|
||||||
depth: 0,
|
...getPublicPublishedQueryOptions(),
|
||||||
limit: 1000,
|
depth: 0,
|
||||||
pagination: false,
|
limit: 1000,
|
||||||
where: {
|
pagination: false,
|
||||||
_status: {
|
select: {
|
||||||
equals: 'published',
|
slug: true,
|
||||||
},
|
spaPath: true,
|
||||||
},
|
updatedAt: true,
|
||||||
select: {
|
},
|
||||||
slug: true,
|
}),
|
||||||
updatedAt: true,
|
),
|
||||||
},
|
)
|
||||||
})
|
|
||||||
|
|
||||||
const dateFallback = new Date().toISOString()
|
const dateFallback = new Date().toISOString()
|
||||||
|
|
||||||
const sitemap = results.docs
|
return results.flatMap((result, index) => {
|
||||||
? results.docs
|
const { prefix } = sitemapCollections[index]
|
||||||
.filter((post) => Boolean(post?.slug))
|
|
||||||
.map((post) => ({
|
|
||||||
loc: `${SITE_URL}/posts/${post?.slug}`,
|
|
||||||
lastmod: post.updatedAt || dateFallback,
|
|
||||||
}))
|
|
||||||
: []
|
|
||||||
|
|
||||||
return sitemap
|
return result.docs.reduce<SitemapEntry[]>((entries, document) => {
|
||||||
|
const entry = createSitemapEntry(
|
||||||
|
document,
|
||||||
|
detailSitemapPath(document, prefix),
|
||||||
|
dateFallback,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (entry) {
|
||||||
|
entries.push(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries
|
||||||
|
}, [])
|
||||||
|
})
|
||||||
},
|
},
|
||||||
['posts-sitemap'],
|
['posts-sitemap'],
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { eventDetailContent } from '@/spa/eventDetailContent'
|
|||||||
import { generatePreviewPath } from '@/utilities/generatePreviewPath'
|
import { generatePreviewPath } from '@/utilities/generatePreviewPath'
|
||||||
import { slugField } from 'payload'
|
import { slugField } from 'payload'
|
||||||
|
|
||||||
|
import { revalidateEvent, revalidateEventDelete } from './Events/hooks/revalidateEvent'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
MetaDescriptionField,
|
MetaDescriptionField,
|
||||||
MetaTitleField,
|
MetaTitleField,
|
||||||
@@ -273,6 +275,8 @@ export const Events: CollectionConfig = {
|
|||||||
slugField(),
|
slugField(),
|
||||||
],
|
],
|
||||||
hooks: {
|
hooks: {
|
||||||
|
afterChange: [revalidateEvent],
|
||||||
|
afterDelete: [revalidateEventDelete],
|
||||||
beforeChange: [populatePublishedAt],
|
beforeChange: [populatePublishedAt],
|
||||||
},
|
},
|
||||||
versions: {
|
versions: {
|
||||||
|
|||||||
46
src/collections/Events/hooks/revalidateEvent.ts
Normal file
46
src/collections/Events/hooks/revalidateEvent.ts
Normal file
@@ -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<Event, 'slug' | 'spaPath'>) =>
|
||||||
|
doc.spaPath || `/presse/events/${doc.slug}`
|
||||||
|
|
||||||
|
export const revalidateEvent: CollectionAfterChangeHook<Event> = ({
|
||||||
|
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<Event> = ({
|
||||||
|
doc,
|
||||||
|
req: { context },
|
||||||
|
}) => {
|
||||||
|
if (!context.disableRevalidate) {
|
||||||
|
revalidatePath(eventPath(doc))
|
||||||
|
revalidateTag('posts-sitemap', 'max')
|
||||||
|
}
|
||||||
|
|
||||||
|
return doc
|
||||||
|
}
|
||||||
@@ -11,7 +11,8 @@ export const revalidatePage: CollectionAfterChangeHook<Page> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
if (!context.disableRevalidate) {
|
if (!context.disableRevalidate) {
|
||||||
if (doc._status === 'published') {
|
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}`)
|
payload.logger.info(`Revalidating page at path: ${path}`)
|
||||||
|
|
||||||
@@ -21,7 +22,11 @@ export const revalidatePage: CollectionAfterChangeHook<Page> = ({
|
|||||||
|
|
||||||
// If the page was previously published, we need to revalidate the old path
|
// If the page was previously published, we need to revalidate the old path
|
||||||
if (previousDoc?._status === 'published' && doc._status !== 'published') {
|
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}`)
|
payload.logger.info(`Revalidating old page at path: ${oldPath}`)
|
||||||
|
|
||||||
@@ -34,7 +39,8 @@ export const revalidatePage: CollectionAfterChangeHook<Page> = ({
|
|||||||
|
|
||||||
export const revalidateDelete: CollectionAfterDeleteHook<Page> = ({ doc, req: { context } }) => {
|
export const revalidateDelete: CollectionAfterDeleteHook<Page> = ({ doc, req: { context } }) => {
|
||||||
if (!context.disableRevalidate) {
|
if (!context.disableRevalidate) {
|
||||||
const path = doc?.slug === 'home' ? '/' : `/${doc?.slug}`
|
const path =
|
||||||
|
doc?.spaPath || (doc?.slug === 'home' || doc?.slug === 'startseite' ? '/' : `/${doc?.slug}`)
|
||||||
revalidatePath(path)
|
revalidatePath(path)
|
||||||
revalidateTag('pages-sitemap', 'max')
|
revalidateTag('pages-sitemap', 'max')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export const revalidatePost: CollectionAfterChangeHook<Post> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
if (!context.disableRevalidate) {
|
if (!context.disableRevalidate) {
|
||||||
if (doc._status === 'published') {
|
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}`)
|
payload.logger.info(`Revalidating post at path: ${path}`)
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@ export const revalidatePost: CollectionAfterChangeHook<Post> = ({
|
|||||||
|
|
||||||
// If the post was previously published, we need to revalidate the old path
|
// If the post was previously published, we need to revalidate the old path
|
||||||
if (previousDoc._status === 'published' && doc._status !== 'published') {
|
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}`)
|
payload.logger.info(`Revalidating old post at path: ${oldPath}`)
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ export const revalidatePost: CollectionAfterChangeHook<Post> = ({
|
|||||||
|
|
||||||
export const revalidateDelete: CollectionAfterDeleteHook<Post> = ({ doc, req: { context } }) => {
|
export const revalidateDelete: CollectionAfterDeleteHook<Post> = ({ doc, req: { context } }) => {
|
||||||
if (!context.disableRevalidate) {
|
if (!context.disableRevalidate) {
|
||||||
const path = doc?.spaPath || `/posts/${doc?.slug}`
|
const path = doc?.spaPath || `/presse/blog/${doc?.slug}`
|
||||||
|
|
||||||
revalidatePath(path)
|
revalidatePath(path)
|
||||||
revalidateTag('posts-sitemap', 'max')
|
revalidateTag('posts-sitemap', 'max')
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ import { preistraegerDetailContent } from '@/spa/preistraegerDetailContent'
|
|||||||
import { generatePreviewPath } from '@/utilities/generatePreviewPath'
|
import { generatePreviewPath } from '@/utilities/generatePreviewPath'
|
||||||
import { slugField } from 'payload'
|
import { slugField } from 'payload'
|
||||||
|
|
||||||
|
import {
|
||||||
|
revalidatePreistraeger,
|
||||||
|
revalidatePreistraegerDelete,
|
||||||
|
} from './Preistraeger/hooks/revalidatePreistraeger'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
MetaDescriptionField,
|
MetaDescriptionField,
|
||||||
MetaTitleField,
|
MetaTitleField,
|
||||||
@@ -372,6 +377,8 @@ export const Preistraeger: CollectionConfig = {
|
|||||||
slugField(),
|
slugField(),
|
||||||
],
|
],
|
||||||
hooks: {
|
hooks: {
|
||||||
|
afterChange: [revalidatePreistraeger],
|
||||||
|
afterDelete: [revalidatePreistraegerDelete],
|
||||||
beforeChange: [populatePublishedAt],
|
beforeChange: [populatePublishedAt],
|
||||||
},
|
},
|
||||||
versions: {
|
versions: {
|
||||||
|
|||||||
46
src/collections/Preistraeger/hooks/revalidatePreistraeger.ts
Normal file
46
src/collections/Preistraeger/hooks/revalidatePreistraeger.ts
Normal file
@@ -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<Preistraeger, 'slug' | 'spaPath'>) =>
|
||||||
|
doc.spaPath || `/preistraeger/${doc.slug}`
|
||||||
|
|
||||||
|
export const revalidatePreistraeger: CollectionAfterChangeHook<Preistraeger> = ({
|
||||||
|
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<Preistraeger> = ({
|
||||||
|
doc,
|
||||||
|
req: { context },
|
||||||
|
}) => {
|
||||||
|
if (!context.disableRevalidate) {
|
||||||
|
revalidatePath(preistraegerPath(doc))
|
||||||
|
revalidateTag('posts-sitemap', 'max')
|
||||||
|
}
|
||||||
|
|
||||||
|
return doc
|
||||||
|
}
|
||||||
@@ -20,3 +20,18 @@ const previewPublicationQuery = Object.freeze({
|
|||||||
export function getPublicationQueryOptions(isPreview: boolean): PublicationQueryOptions {
|
export function getPublicationQueryOptions(isPreview: boolean): PublicationQueryOptions {
|
||||||
return isPreview ? previewPublicationQuery : publicPublicationQuery
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
58
src/utilities/sitemapBaseURL.cjs
Normal file
58
src/utilities/sitemapBaseURL.cjs
Normal file
@@ -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<string, string | undefined>} [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,
|
||||||
|
}
|
||||||
65
src/utilities/sitemapRoutes.ts
Normal file
65
src/utilities/sitemapRoutes.ts
Normal file
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
210
tests/int/sitemap-routes.int.spec.ts
Normal file
210
tests/int/sitemap-routes.int.spec.ts
Normal file
@@ -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<SitemapCollection, SitemapDocument[]>,
|
||||||
|
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<string[]> {
|
||||||
|
const xml = await response.text()
|
||||||
|
return [...xml.matchAll(/<loc>([^<]+)<\/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`,
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user