added auto generating for sitemap.

This commit is contained in:
2026-07-31 23:12:43 +02:00
parent 24a1fe0d60
commit f6b4b36df8
10 changed files with 428 additions and 12 deletions
+212
View File
@@ -0,0 +1,212 @@
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, 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?: 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
}
/** Skip drafts and anything flagged noindex in the SEO tab. */
function isIndexable(doc: DocRow): boolean {
if (doc._status && doc._status !== 'published') {return false}
if (doc.meta?.noindex) {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: 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>
const resolvedHomeSlug =
homeSlug ?? 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 the entry under the default locale's URL; alternates cover the rest.
const entry = entryFor(
raw,
defaultLocale,
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
}