248 lines
8.7 KiB
TypeScript
248 lines
8.7 KiB
TypeScript
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 `<xhtml:link rel="alternate" hreflang=…>` 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<string, string> }
|
||
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<string, string>
|
||
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, string> | 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<string, unknown>)) {
|
||
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, string> | string | undefined,
|
||
prefix: Record<string, string> | undefined,
|
||
changeFrequency: SitemapEntry['changeFrequency'],
|
||
): null | SitemapEntry {
|
||
const slugs =
|
||
doc.slug && typeof doc.slug === 'object'
|
||
? getLocalizedSlugs({ config, slugField: doc.slug as Record<string, unknown> })
|
||
: {}
|
||
|
||
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<SitemapEntry[]> {
|
||
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<string, unknown>
|
||
|
||
// 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 <url> for EACH locale. Google's sitemap spec for
|
||
// localized sites requires one <url> per language version (each with its
|
||
// own <loc> plus xhtml:link alternates), not a single default-locale <url>
|
||
// 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<string, unknown>)[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<string, string> | 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<string, string> = {}
|
||
for (const locale of locales) {
|
||
const v = (slug as Record<string, unknown>)[locale]
|
||
if (typeof v === 'string') {map[locale] = v}
|
||
}
|
||
return Object.keys(map).length ? map : undefined
|
||
}
|