import { getLocalizedSlugs } from '../i18n/index.js'; import { buildLocalizedPath } from '../i18n/index.js'; import { buildHreflangAlternates } from './hreflang.js'; import { archiveFieldName } from '../content/index.js'; /** * 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', 'not-found', 'error' ]); /** True if the doc's slug (in any locale) is an excluded system/error slug. */ function hasExcludedSlug(slug) { if (typeof slug === 'string') return EXCLUDED_SITEMAP_SLUGS.has(slug); if (slug && typeof slug === 'object') { for (const value of Object.values(slug)){ 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) { 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, locale, config, baseUrl, homeSlug, prefix, changeFrequency) { const slugs = doc.slug && typeof doc.slug === 'object' ? getLocalizedSlugs({ slugField: doc.slug, config }) : {}; const path = buildLocalizedPath({ slugs, locale, config, homeSlug, prefix }); if (!path) return null; const origin = baseUrl.replace(/\/$/, ''); const languages = buildHreflangAlternates({ slugs, config, baseUrl, homeSlug, prefix }); 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({ payload, config, baseUrl, pagesSlug = 'pages', content, settingsSlug = 'site-settings', homeSlug, changeFrequency = 'weekly' }) { 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, locale: 'all', depth: 1 }); 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 = [ { 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 = []; 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, locale: 'all', depth: 0, limit: 0, pagination: false }); for (const raw of result.docs){ 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, locale) { if (!rel || typeof rel !== 'object') return undefined; const slug = rel.slug; if (typeof slug === 'string') return slug; if (slug && typeof slug === 'object') { const v = slug[locale]; return typeof v === 'string' ? v : undefined; } return undefined; } /** Builds a locale→slug map from a populated archive relationship. */ function slugMapAllLocales(rel, locales) { if (!rel || typeof rel !== 'object') return undefined; const slug = rel.slug; if (!slug || typeof slug !== 'object') return undefined; const map = {}; for (const locale of locales){ const v = slug[locale]; if (typeof v === 'string') map[locale] = v; } return Object.keys(map).length ? map : undefined; } //# sourceMappingURL=buildSitemapEntries.js.map