import type { BasePayload } from 'payload' import type { ContentOption } from '../content/index.js' import type { I18nConfig } from '../i18n/index.js' import { archiveFieldName } from '../content/index.js' import { buildLocalizedPath, getLocaleCodes , getLocalizedSlugs } from '../i18n/index.js' import { buildHreflangAlternates } from './hreflang.js' /** * One sitemap entry, shaped for Next's `app/sitemap.ts`. * * `alternates.languages` is the important part for a multilingual site: Next * renders it as `` per URL, which is * exactly what Google uses to connect language versions. A sitemap without it * is the common, weaker kind. */ export type SitemapEntry = { alternates?: { languages: Record } changeFrequency?: 'always' | 'daily' | 'hourly' | 'monthly' | 'never' | 'weekly' | 'yearly' lastModified?: Date | string priority?: number url: string } type CollectionEntry = { /** Localized segment for entries (archive page slugs), when applicable. */ prefixSlugs?: Record slug: string } type BuildSitemapArgs = { /** Absolute origin, e.g. 'https://example.com'. Required for valid sitemap URLs. */ baseUrl: string changeFrequency?: SitemapEntry['changeFrequency'] config: I18nConfig /** Archive-backed collections, same value as the plugin option. */ content?: ContentOption /** * Slug of the page that is the site root (collapses to /{locale}). * Read from System Pages when omitted. */ homeSlug?: Record | string /** Pages collection slug. Defaults to 'pages'. */ pagesSlug?: string payload: BasePayload /** SiteSettings global slug. Defaults to 'site-settings'. */ settingsSlug?: string } type DocRow = { _status?: string id: number | string meta?: { noindex?: boolean } | null slug?: unknown updatedAt?: string } /** * Slugs that must never appear in the sitemap — error/system pages that exist as * documents (e.g. a '404' page in the Pages collection) but should not be * indexed. A sitemap should list only real, HTTP-200 content; a '/pl/404' entry * is an audit finding. Matched against the slug in any locale. */ const EXCLUDED_SITEMAP_SLUGS = new Set(['404', '500', 'error', 'not-found']) /** True if the doc's slug (in any locale) is an excluded system/error slug. */ function hasExcludedSlug(slug: unknown): boolean { if (typeof slug === 'string') {return EXCLUDED_SITEMAP_SLUGS.has(slug)} if (slug && typeof slug === 'object') { for (const value of Object.values(slug as Record)) { if (typeof value === 'string' && EXCLUDED_SITEMAP_SLUGS.has(value)) {return true} } } return false } /** Skip drafts, noindex, and system/error pages (404 etc.). */ function isIndexable(doc: DocRow): boolean { if (doc._status && doc._status !== 'published') {return false} if (doc.meta?.noindex) {return false} if (hasExcludedSlug(doc.slug)) {return false} return true } /** * Turns one document into a sitemap entry per default locale isn't needed — * one entry with all languages as alternates is the correct, de-duplicated * shape. The `url` is the current locale's path; `alternates.languages` carries * every locale (including itself, per Google's guidance). */ function entryFor( doc: DocRow, locale: string, config: I18nConfig, baseUrl: string, homeSlug: Record | string | undefined, prefix: Record | undefined, changeFrequency: SitemapEntry['changeFrequency'], ): null | SitemapEntry { const slugs = doc.slug && typeof doc.slug === 'object' ? getLocalizedSlugs({ config, slugField: doc.slug as Record }) : {} const path = buildLocalizedPath({ config, homeSlug, locale, prefix, slugs }) if (!path) {return null} const origin = baseUrl.replace(/\/$/, '') const languages = buildHreflangAlternates({ baseUrl, config, homeSlug, prefix, slugs }) return { url: `${origin}${path}`, ...(doc.updatedAt ? { lastModified: doc.updatedAt } : {}), ...(changeFrequency ? { changeFrequency } : {}), ...(Object.keys(languages).length ? { alternates: { languages } } : {}), } } /** * Collects every public URL — pages and archive entries — as sitemap entries * with per-URL hreflang and lastmod. * * Reuses the same path/hreflang builders as page metadata, so the sitemap can't * drift from what the pages actually render (a classic source of sitemap bugs: * a URL listed one way and served another). Drafts and noindex documents are * omitted. * * ```ts * // app/sitemap.ts * export default async function sitemap() { * return buildSitemapEntries({ * payload: await getPayload({ config }), * config: i18nConfig, * baseUrl: process.env.NEXT_PUBLIC_SERVER_URL!, * content: contentConfig, * }) * } * ``` */ export async function buildSitemapEntries({ baseUrl, changeFrequency = 'weekly', config, content, homeSlug, pagesSlug = 'pages', payload, settingsSlug = 'site-settings', }: BuildSitemapArgs): Promise { const locales = config.locales.map((l) => l.code) const defaultLocale = config.defaultLocale // Resolve homeSlug and archive prefixes from System Pages (read once, in all // locales so archive prefixes are available per language). const settings = (await payload.findGlobal({ slug: settingsSlug as never, depth: 1, locale: 'all' as never, })) as Record // Home slug as a per-locale MAP so every language's homepage collapses to its // root in the sitemap (/pl, /de, /en) — not just the default locale. Priority: // caller-provided homeSlug > full map from the homepage relationship > single // default-locale slug > 'home'. slugMapAllLocales already builds the map (it's // used for archive prefixes below); reusing it here fixes /de/startseite // appearing in the sitemap instead of /de. const resolvedHomeSlug = homeSlug ?? slugMapAllLocales(settings.homepage, locales) ?? extractSlugInLocale(settings.homepage, defaultLocale) ?? 'home' // Which collections to walk: pages (no prefix) + each content collection with // its archive-page slugs as the localized prefix. const collections: CollectionEntry[] = [{ slug: pagesSlug }] for (const c of content?.collections ?? []) { const archive = settings[archiveFieldName(c.slug)] const prefixSlugs = archive ? slugMapAllLocales(archive, locales) : undefined collections.push({ slug: c.slug, prefixSlugs }) } const entries: SitemapEntry[] = [] for (const collection of collections) { // Read every doc once, across locales, so one row yields all its language // alternates without re-querying per locale. const result = await payload.find({ collection: collection.slug as never, depth: 0, limit: 0, // no pagination — sitemap wants everything locale: 'all' as never, pagination: false as never, }) for (const raw of result.docs as DocRow[]) { if (!isIndexable(raw)) {continue} // Emit a separate for EACH locale. Google's sitemap spec for // localized sites requires one per language version (each with its // own plus xhtml:link alternates), not a single default-locale // with the others hidden only in alternates. Emitting only the default // locale makes GSC count 8 URLs instead of 8×3 — a real reporting gap. for (const locale of getLocaleCodes(config)) { const entry = entryFor( raw, locale, config, baseUrl, resolvedHomeSlug, collection.prefixSlugs, changeFrequency, ) if (entry) {entries.push(entry)} } } } return entries } /** Pulls a slug string from a populated relationship in a specific locale. */ function extractSlugInLocale(rel: unknown, locale: string): string | undefined { if (!rel || typeof rel !== 'object') {return undefined} const slug = (rel as { slug?: unknown }).slug if (typeof slug === 'string') {return slug} if (slug && typeof slug === 'object') { const v = (slug as Record)[locale] return typeof v === 'string' ? v : undefined } return undefined } /** Builds a locale→slug map from a populated archive relationship. */ function slugMapAllLocales(rel: unknown, locales: string[]): Record | undefined { if (!rel || typeof rel !== 'object') {return undefined} const slug = (rel as { slug?: unknown }).slug if (!slug || typeof slug !== 'object') {return undefined} const map: Record = {} for (const locale of locales) { const v = (slug as Record)[locale] if (typeof v === 'string') {map[locale] = v} } return Object.keys(map).length ? map : undefined }