Files
ipal-kit/src/modules/seo/buildSitemapEntries.ts
T

248 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}