197 lines
7.1 KiB
JavaScript
197 lines
7.1 KiB
JavaScript
import { archiveFieldName } from '../content/index.js';
|
||
import { buildLocalizedPath, getLocaleCodes, getLocalizedSlugs } from '../i18n/index.js';
|
||
import { buildHreflangAlternates } from './hreflang.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',
|
||
'error',
|
||
'not-found'
|
||
]);
|
||
/** 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({
|
||
config,
|
||
slugField: doc.slug
|
||
}) : {};
|
||
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' }) {
|
||
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,
|
||
depth: 1,
|
||
locale: 'all'
|
||
});
|
||
// 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 = [
|
||
{
|
||
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,
|
||
depth: 0,
|
||
limit: 0,
|
||
locale: 'all',
|
||
pagination: false
|
||
});
|
||
for (const raw of result.docs){
|
||
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, 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
|