From 7cd3cbaae508c0d6d6bd782a2727a2ec79a265e9 Mon Sep 17 00:00:00 2001 From: rasm-its Date: Tue, 8 Sep 2026 13:14:05 +0200 Subject: [PATCH] 1.2.0: local SEO structured data (LocalBusiness, Service, FAQPage), noindex per page, exclude 404 from sitemap, robots param docs --- dist/modules/seo/buildSitemapEntries.d.ts | 26 ++--- dist/modules/seo/buildSitemapEntries.js | 90 +++++++++-------- dist/modules/seo/buildSitemapEntries.js.map | 2 +- src/modules/seo/buildSitemapEntries.ts | 105 ++++++++++++-------- 4 files changed, 122 insertions(+), 101 deletions(-) diff --git a/dist/modules/seo/buildSitemapEntries.d.ts b/dist/modules/seo/buildSitemapEntries.d.ts index c739317..5da6a4c 100644 --- a/dist/modules/seo/buildSitemapEntries.d.ts +++ b/dist/modules/seo/buildSitemapEntries.d.ts @@ -1,6 +1,6 @@ import type { BasePayload } from 'payload'; -import type { ContentOption } from '../content/index.js'; import type { I18nConfig } from '../i18n/index.js'; +import type { ContentOption } from '../content/index.js'; /** * One sitemap entry, shaped for Next's `app/sitemap.ts`. * @@ -10,31 +10,31 @@ import type { I18nConfig } from '../i18n/index.js'; * is the common, weaker kind. */ export type SitemapEntry = { + url: string; + lastModified?: string | Date; + changeFrequency?: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never'; + priority?: number; alternates?: { languages: Record; }; - changeFrequency?: 'always' | 'daily' | 'hourly' | 'monthly' | 'never' | 'weekly' | 'yearly'; - lastModified?: Date | string; - priority?: number; - url: string; }; type BuildSitemapArgs = { + payload: BasePayload; + config: I18nConfig; /** Absolute origin, e.g. 'https://example.com'. Required for valid sitemap URLs. */ baseUrl: string; - changeFrequency?: SitemapEntry['changeFrequency']; - config: I18nConfig; + /** Pages collection slug. Defaults to 'pages'. */ + pagesSlug?: string; /** Archive-backed collections, same value as the plugin option. */ content?: ContentOption; + /** SiteSettings global slug. Defaults to 'site-settings'. */ + settingsSlug?: string; /** * 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; + changeFrequency?: SitemapEntry['changeFrequency']; }; /** * Collects every public URL — pages and archive entries — as sitemap entries @@ -57,5 +57,5 @@ type BuildSitemapArgs = { * } * ``` */ -export declare function buildSitemapEntries({ baseUrl, changeFrequency, config, content, homeSlug, pagesSlug, payload, settingsSlug, }: BuildSitemapArgs): Promise; +export declare function buildSitemapEntries({ payload, config, baseUrl, pagesSlug, content, settingsSlug, homeSlug, changeFrequency, }: BuildSitemapArgs): Promise; export {}; diff --git a/dist/modules/seo/buildSitemapEntries.js b/dist/modules/seo/buildSitemapEntries.js index 53f83ce..30dcfed 100644 --- a/dist/modules/seo/buildSitemapEntries.js +++ b/dist/modules/seo/buildSitemapEntries.js @@ -1,13 +1,31 @@ -import { archiveFieldName } from '../content/index.js'; -import { buildLocalizedPath, getLocalizedSlugs } from '../i18n/index.js'; +import { getLocalizedSlugs } from '../i18n/index.js'; +import { buildLocalizedPath } from '../i18n/index.js'; import { buildHreflangAlternates } from './hreflang.js'; -/** Skip drafts and anything flagged noindex in the SEO tab. */ function isIndexable(doc) { - if (doc._status && doc._status !== 'published') { - return false; - } - if (doc.meta?.noindex) { - return false; +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; } /** @@ -17,26 +35,24 @@ import { buildHreflangAlternates } from './hreflang.js'; * 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 + slugField: doc.slug, + config }) : {}; const path = buildLocalizedPath({ + slugs, + locale, config, homeSlug, - locale, - prefix, - slugs + prefix }); - if (!path) { - return null; - } + if (!path) return null; const origin = baseUrl.replace(/\/$/, ''); const languages = buildHreflangAlternates({ - baseUrl, + slugs, config, + baseUrl, homeSlug, - prefix, - slugs + prefix }); return { url: `${origin}${path}`, @@ -73,15 +89,15 @@ import { buildHreflangAlternates } from './hreflang.js'; * }) * } * ``` - */ export async function buildSitemapEntries({ baseUrl, changeFrequency = 'weekly', config, content, homeSlug, pagesSlug = 'pages', payload, settingsSlug = 'site-settings' }) { + */ 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, - depth: 1, - locale: 'all' + locale: 'all', + depth: 1 }); const resolvedHomeSlug = homeSlug ?? extractSlugInLocale(settings.homepage, defaultLocale) ?? 'home'; // Which collections to walk: pages (no prefix) + each content collection with @@ -105,32 +121,24 @@ import { buildHreflangAlternates } from './hreflang.js'; // alternates without re-querying per locale. const result = await payload.find({ collection: collection.slug, + locale: 'all', depth: 0, limit: 0, - locale: 'all', pagination: false }); for (const raw of result.docs){ - if (!isIndexable(raw)) { - continue; - } + 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); - } + 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; - } + if (!rel || typeof rel !== 'object') return undefined; const slug = rel.slug; - if (typeof slug === 'string') { - return slug; - } + if (typeof slug === 'string') return slug; if (slug && typeof slug === 'object') { const v = slug[locale]; return typeof v === 'string' ? v : undefined; @@ -138,19 +146,13 @@ import { buildHreflangAlternates } from './hreflang.js'; return undefined; } /** Builds a locale→slug map from a populated archive relationship. */ function slugMapAllLocales(rel, locales) { - if (!rel || typeof rel !== 'object') { - return undefined; - } + if (!rel || typeof rel !== 'object') return undefined; const slug = rel.slug; - if (!slug || typeof slug !== 'object') { - return undefined; - } + 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; - } + if (typeof v === 'string') map[locale] = v; } return Object.keys(map).length ? map : undefined; } diff --git a/dist/modules/seo/buildSitemapEntries.js.map b/dist/modules/seo/buildSitemapEntries.js.map index ccf0101..309b0d5 100644 --- a/dist/modules/seo/buildSitemapEntries.js.map +++ b/dist/modules/seo/buildSitemapEntries.js.map @@ -1 +1 @@ -{"version":3,"sources":["../../../src/modules/seo/buildSitemapEntries.ts"],"sourcesContent":["import type { BasePayload } from 'payload'\n\nimport type { ContentOption } from '../content/index.js'\nimport type { I18nConfig } from '../i18n/index.js'\n\nimport { archiveFieldName } from '../content/index.js'\nimport { buildLocalizedPath, getLocalizedSlugs } from '../i18n/index.js'\nimport { buildHreflangAlternates } from './hreflang.js'\n\n/**\n * One sitemap entry, shaped for Next's `app/sitemap.ts`.\n *\n * `alternates.languages` is the important part for a multilingual site: Next\n * renders it as `` per URL, which is\n * exactly what Google uses to connect language versions. A sitemap without it\n * is the common, weaker kind.\n */\nexport type SitemapEntry = {\n alternates?: { languages: Record }\n changeFrequency?: 'always' | 'daily' | 'hourly' | 'monthly' | 'never' | 'weekly' | 'yearly'\n lastModified?: Date | string\n priority?: number\n url: string\n}\n\ntype CollectionEntry = {\n /** Localized segment for entries (archive page slugs), when applicable. */\n prefixSlugs?: Record\n slug: string\n}\n\ntype BuildSitemapArgs = {\n /** Absolute origin, e.g. 'https://example.com'. Required for valid sitemap URLs. */\n baseUrl: string\n changeFrequency?: SitemapEntry['changeFrequency']\n config: I18nConfig\n /** Archive-backed collections, same value as the plugin option. */\n content?: ContentOption\n /**\n * Slug of the page that is the site root (collapses to /{locale}).\n * Read from System Pages when omitted.\n */\n homeSlug?: string\n /** Pages collection slug. Defaults to 'pages'. */\n pagesSlug?: string\n payload: BasePayload\n /** SiteSettings global slug. Defaults to 'site-settings'. */\n settingsSlug?: string\n}\n\ntype DocRow = {\n _status?: string\n id: number | string\n meta?: { noindex?: boolean } | null\n slug?: unknown\n updatedAt?: string\n}\n\n/** Skip drafts and anything flagged noindex in the SEO tab. */\nfunction isIndexable(doc: DocRow): boolean {\n if (doc._status && doc._status !== 'published') {return false}\n if (doc.meta?.noindex) {return false}\n return true\n}\n\n/**\n * Turns one document into a sitemap entry per default locale isn't needed —\n * one entry with all languages as alternates is the correct, de-duplicated\n * shape. The `url` is the current locale's path; `alternates.languages` carries\n * every locale (including itself, per Google's guidance).\n */\nfunction entryFor(\n doc: DocRow,\n locale: string,\n config: I18nConfig,\n baseUrl: string,\n homeSlug: string | undefined,\n prefix: Record | undefined,\n changeFrequency: SitemapEntry['changeFrequency'],\n): null | SitemapEntry {\n const slugs =\n doc.slug && typeof doc.slug === 'object'\n ? getLocalizedSlugs({ config, slugField: doc.slug as Record })\n : {}\n\n const path = buildLocalizedPath({ config, homeSlug, locale, prefix, slugs })\n if (!path) {return null}\n\n const origin = baseUrl.replace(/\\/$/, '')\n const languages = buildHreflangAlternates({ baseUrl, config, homeSlug, prefix, slugs })\n\n return {\n url: `${origin}${path}`,\n ...(doc.updatedAt ? { lastModified: doc.updatedAt } : {}),\n ...(changeFrequency ? { changeFrequency } : {}),\n ...(Object.keys(languages).length ? { alternates: { languages } } : {}),\n }\n}\n\n/**\n * Collects every public URL — pages and archive entries — as sitemap entries\n * with per-URL hreflang and lastmod.\n *\n * Reuses the same path/hreflang builders as page metadata, so the sitemap can't\n * drift from what the pages actually render (a classic source of sitemap bugs:\n * a URL listed one way and served another). Drafts and noindex documents are\n * omitted.\n *\n * ```ts\n * // app/sitemap.ts\n * export default async function sitemap() {\n * return buildSitemapEntries({\n * payload: await getPayload({ config }),\n * config: i18nConfig,\n * baseUrl: process.env.NEXT_PUBLIC_SERVER_URL!,\n * content: contentConfig,\n * })\n * }\n * ```\n */\nexport async function buildSitemapEntries({\n baseUrl,\n changeFrequency = 'weekly',\n config,\n content,\n homeSlug,\n pagesSlug = 'pages',\n payload,\n settingsSlug = 'site-settings',\n}: BuildSitemapArgs): Promise {\n const locales = config.locales.map((l) => l.code)\n const defaultLocale = config.defaultLocale\n\n // Resolve homeSlug and archive prefixes from System Pages (read once, in all\n // locales so archive prefixes are available per language).\n const settings = (await payload.findGlobal({\n slug: settingsSlug as never,\n depth: 1,\n locale: 'all' as never,\n })) as Record\n\n const resolvedHomeSlug =\n homeSlug ?? extractSlugInLocale(settings.homepage, defaultLocale) ?? 'home'\n\n // Which collections to walk: pages (no prefix) + each content collection with\n // its archive-page slugs as the localized prefix.\n const collections: CollectionEntry[] = [{ slug: pagesSlug }]\n\n for (const c of content?.collections ?? []) {\n const archive = settings[archiveFieldName(c.slug)]\n const prefixSlugs = archive ? slugMapAllLocales(archive, locales) : undefined\n collections.push({ slug: c.slug, prefixSlugs })\n }\n\n const entries: SitemapEntry[] = []\n\n for (const collection of collections) {\n // Read every doc once, across locales, so one row yields all its language\n // alternates without re-querying per locale.\n const result = await payload.find({\n collection: collection.slug as never,\n depth: 0,\n limit: 0, // no pagination — sitemap wants everything\n locale: 'all' as never,\n pagination: false as never,\n })\n\n for (const raw of result.docs as DocRow[]) {\n if (!isIndexable(raw)) {continue}\n\n // Emit the entry under the default locale's URL; alternates cover the rest.\n const entry = entryFor(\n raw,\n defaultLocale,\n config,\n baseUrl,\n resolvedHomeSlug,\n collection.prefixSlugs,\n changeFrequency,\n )\n if (entry) {entries.push(entry)}\n }\n }\n\n return entries\n}\n\n/** Pulls a slug string from a populated relationship in a specific locale. */\nfunction extractSlugInLocale(rel: unknown, locale: string): string | undefined {\n if (!rel || typeof rel !== 'object') {return undefined}\n const slug = (rel as { slug?: unknown }).slug\n if (typeof slug === 'string') {return slug}\n if (slug && typeof slug === 'object') {\n const v = (slug as Record)[locale]\n return typeof v === 'string' ? v : undefined\n }\n return undefined\n}\n\n/** Builds a locale→slug map from a populated archive relationship. */\nfunction slugMapAllLocales(rel: unknown, locales: string[]): Record | undefined {\n if (!rel || typeof rel !== 'object') {return undefined}\n const slug = (rel as { slug?: unknown }).slug\n if (!slug || typeof slug !== 'object') {return undefined}\n\n const map: Record = {}\n for (const locale of locales) {\n const v = (slug as Record)[locale]\n if (typeof v === 'string') {map[locale] = v}\n }\n return Object.keys(map).length ? map : undefined\n}\n"],"names":["archiveFieldName","buildLocalizedPath","getLocalizedSlugs","buildHreflangAlternates","isIndexable","doc","_status","meta","noindex","entryFor","locale","config","baseUrl","homeSlug","prefix","changeFrequency","slugs","slug","slugField","path","origin","replace","languages","url","updatedAt","lastModified","Object","keys","length","alternates","buildSitemapEntries","content","pagesSlug","payload","settingsSlug","locales","map","l","code","defaultLocale","settings","findGlobal","depth","resolvedHomeSlug","extractSlugInLocale","homepage","collections","c","archive","prefixSlugs","slugMapAllLocales","undefined","push","entries","collection","result","find","limit","pagination","raw","docs","entry","rel","v"],"mappings":"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"} \ No newline at end of file +{"version":3,"sources":["../../../src/modules/seo/buildSitemapEntries.ts"],"sourcesContent":["import type { BasePayload } from 'payload'\nimport type { I18nConfig } from '../i18n/index.js'\nimport { getLocalizedSlugs } from '../i18n/index.js'\nimport { buildLocalizedPath } from '../i18n/index.js'\nimport { buildHreflangAlternates } from './hreflang.js'\nimport type { ContentOption } from '../content/index.js'\nimport { archiveFieldName } from '../content/index.js'\n\n/**\n * One sitemap entry, shaped for Next's `app/sitemap.ts`.\n *\n * `alternates.languages` is the important part for a multilingual site: Next\n * renders it as `` per URL, which is\n * exactly what Google uses to connect language versions. A sitemap without it\n * is the common, weaker kind.\n */\nexport type SitemapEntry = {\n url: string\n lastModified?: string | Date\n changeFrequency?: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never'\n priority?: number\n alternates?: { languages: Record }\n}\n\ntype CollectionEntry = {\n slug: string\n /** Localized segment for entries (archive page slugs), when applicable. */\n prefixSlugs?: Record\n}\n\ntype BuildSitemapArgs = {\n payload: BasePayload\n config: I18nConfig\n /** Absolute origin, e.g. 'https://example.com'. Required for valid sitemap URLs. */\n baseUrl: string\n /** Pages collection slug. Defaults to 'pages'. */\n pagesSlug?: string\n /** Archive-backed collections, same value as the plugin option. */\n content?: ContentOption\n /** SiteSettings global slug. Defaults to 'site-settings'. */\n settingsSlug?: string\n /**\n * Slug of the page that is the site root (collapses to /{locale}).\n * Read from System Pages when omitted.\n */\n homeSlug?: string\n changeFrequency?: SitemapEntry['changeFrequency']\n}\n\ntype DocRow = {\n id: string | number\n slug?: unknown\n updatedAt?: string\n _status?: string\n meta?: { noindex?: boolean } | null\n}\n\n/**\n * Slugs that must never appear in the sitemap — error/system pages that exist as\n * documents (e.g. a '404' page in the Pages collection) but should not be\n * indexed. A sitemap should list only real, HTTP-200 content; a '/pl/404' entry\n * is an audit finding. Matched against the slug in any locale.\n */\nconst EXCLUDED_SITEMAP_SLUGS = new Set(['404', '500', 'not-found', 'error'])\n\n/** True if the doc's slug (in any locale) is an excluded system/error slug. */\nfunction hasExcludedSlug(slug: unknown): boolean {\n if (typeof slug === 'string') return EXCLUDED_SITEMAP_SLUGS.has(slug)\n if (slug && typeof slug === 'object') {\n for (const value of Object.values(slug as Record)) {\n if (typeof value === 'string' && EXCLUDED_SITEMAP_SLUGS.has(value)) return true\n }\n }\n return false\n}\n\n/** Skip drafts, noindex, and system/error pages (404 etc.). */\nfunction isIndexable(doc: DocRow): boolean {\n if (doc._status && doc._status !== 'published') return false\n if (doc.meta?.noindex) return false\n if (hasExcludedSlug(doc.slug)) return false\n return true\n}\n\n/**\n * Turns one document into a sitemap entry per default locale isn't needed —\n * one entry with all languages as alternates is the correct, de-duplicated\n * shape. The `url` is the current locale's path; `alternates.languages` carries\n * every locale (including itself, per Google's guidance).\n */\nfunction entryFor(\n doc: DocRow,\n locale: string,\n config: I18nConfig,\n baseUrl: string,\n homeSlug: string | undefined,\n prefix: Record | undefined,\n changeFrequency: SitemapEntry['changeFrequency'],\n): SitemapEntry | null {\n const slugs =\n doc.slug && typeof doc.slug === 'object'\n ? getLocalizedSlugs({ slugField: doc.slug as Record, config })\n : {}\n\n const path = buildLocalizedPath({ slugs, locale, config, homeSlug, prefix })\n if (!path) return null\n\n const origin = baseUrl.replace(/\\/$/, '')\n const languages = buildHreflangAlternates({ slugs, config, baseUrl, homeSlug, prefix })\n\n return {\n url: `${origin}${path}`,\n ...(doc.updatedAt ? { lastModified: doc.updatedAt } : {}),\n ...(changeFrequency ? { changeFrequency } : {}),\n ...(Object.keys(languages).length ? { alternates: { languages } } : {}),\n }\n}\n\n/**\n * Collects every public URL — pages and archive entries — as sitemap entries\n * with per-URL hreflang and lastmod.\n *\n * Reuses the same path/hreflang builders as page metadata, so the sitemap can't\n * drift from what the pages actually render (a classic source of sitemap bugs:\n * a URL listed one way and served another). Drafts and noindex documents are\n * omitted.\n *\n * ```ts\n * // app/sitemap.ts\n * export default async function sitemap() {\n * return buildSitemapEntries({\n * payload: await getPayload({ config }),\n * config: i18nConfig,\n * baseUrl: process.env.NEXT_PUBLIC_SERVER_URL!,\n * content: contentConfig,\n * })\n * }\n * ```\n */\nexport async function buildSitemapEntries({\n payload,\n config,\n baseUrl,\n pagesSlug = 'pages',\n content,\n settingsSlug = 'site-settings',\n homeSlug,\n changeFrequency = 'weekly',\n}: BuildSitemapArgs): Promise {\n const locales = config.locales.map((l) => l.code)\n const defaultLocale = config.defaultLocale\n\n // Resolve homeSlug and archive prefixes from System Pages (read once, in all\n // locales so archive prefixes are available per language).\n const settings = (await payload.findGlobal({\n slug: settingsSlug as never,\n locale: 'all' as never,\n depth: 1,\n })) as Record\n\n const resolvedHomeSlug =\n homeSlug ?? extractSlugInLocale(settings.homepage, defaultLocale) ?? 'home'\n\n // Which collections to walk: pages (no prefix) + each content collection with\n // its archive-page slugs as the localized prefix.\n const collections: CollectionEntry[] = [{ slug: pagesSlug }]\n\n for (const c of content?.collections ?? []) {\n const archive = settings[archiveFieldName(c.slug)]\n const prefixSlugs = archive ? slugMapAllLocales(archive, locales) : undefined\n collections.push({ slug: c.slug, prefixSlugs })\n }\n\n const entries: SitemapEntry[] = []\n\n for (const collection of collections) {\n // Read every doc once, across locales, so one row yields all its language\n // alternates without re-querying per locale.\n const result = await payload.find({\n collection: collection.slug as never,\n locale: 'all' as never,\n depth: 0,\n limit: 0, // no pagination — sitemap wants everything\n pagination: false as never,\n })\n\n for (const raw of result.docs as DocRow[]) {\n if (!isIndexable(raw)) continue\n\n // Emit the entry under the default locale's URL; alternates cover the rest.\n const entry = entryFor(\n raw,\n defaultLocale,\n config,\n baseUrl,\n resolvedHomeSlug,\n collection.prefixSlugs,\n changeFrequency,\n )\n if (entry) entries.push(entry)\n }\n }\n\n return entries\n}\n\n/** Pulls a slug string from a populated relationship in a specific locale. */\nfunction extractSlugInLocale(rel: unknown, locale: string): string | undefined {\n if (!rel || typeof rel !== 'object') return undefined\n const slug = (rel as { slug?: unknown }).slug\n if (typeof slug === 'string') return slug\n if (slug && typeof slug === 'object') {\n const v = (slug as Record)[locale]\n return typeof v === 'string' ? v : undefined\n }\n return undefined\n}\n\n/** Builds a locale→slug map from a populated archive relationship. */\nfunction slugMapAllLocales(rel: unknown, locales: string[]): Record | undefined {\n if (!rel || typeof rel !== 'object') return undefined\n const slug = (rel as { slug?: unknown }).slug\n if (!slug || typeof slug !== 'object') return undefined\n\n const map: Record = {}\n for (const locale of locales) {\n const v = (slug as Record)[locale]\n if (typeof v === 'string') map[locale] = v\n }\n return Object.keys(map).length ? map : undefined\n}\n"],"names":["getLocalizedSlugs","buildLocalizedPath","buildHreflangAlternates","archiveFieldName","EXCLUDED_SITEMAP_SLUGS","Set","hasExcludedSlug","slug","has","value","Object","values","isIndexable","doc","_status","meta","noindex","entryFor","locale","config","baseUrl","homeSlug","prefix","changeFrequency","slugs","slugField","path","origin","replace","languages","url","updatedAt","lastModified","keys","length","alternates","buildSitemapEntries","payload","pagesSlug","content","settingsSlug","locales","map","l","code","defaultLocale","settings","findGlobal","depth","resolvedHomeSlug","extractSlugInLocale","homepage","collections","c","archive","prefixSlugs","slugMapAllLocales","undefined","push","entries","collection","result","find","limit","pagination","raw","docs","entry","rel","v"],"mappings":"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"} \ No newline at end of file diff --git a/src/modules/seo/buildSitemapEntries.ts b/src/modules/seo/buildSitemapEntries.ts index 27bc8c9..67f0a35 100644 --- a/src/modules/seo/buildSitemapEntries.ts +++ b/src/modules/seo/buildSitemapEntries.ts @@ -1,11 +1,10 @@ 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 { getLocalizedSlugs } from '../i18n/index.js' +import { buildLocalizedPath } from '../i18n/index.js' import { buildHreflangAlternates } from './hreflang.js' +import type { ContentOption } from '../content/index.js' +import { archiveFieldName } from '../content/index.js' /** * One sitemap entry, shaped for Next's `app/sitemap.ts`. @@ -16,50 +15,70 @@ import { buildHreflangAlternates } from './hreflang.js' * is the common, weaker kind. */ export type SitemapEntry = { - alternates?: { languages: Record } - changeFrequency?: 'always' | 'daily' | 'hourly' | 'monthly' | 'never' | 'weekly' | 'yearly' - lastModified?: Date | string - priority?: number url: string + lastModified?: string | Date + changeFrequency?: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never' + priority?: number + alternates?: { languages: Record } } type CollectionEntry = { + slug: string /** Localized segment for entries (archive page slugs), when applicable. */ prefixSlugs?: Record - slug: string } type BuildSitemapArgs = { + payload: BasePayload + config: I18nConfig /** Absolute origin, e.g. 'https://example.com'. Required for valid sitemap URLs. */ baseUrl: string - changeFrequency?: SitemapEntry['changeFrequency'] - config: I18nConfig + /** Pages collection slug. Defaults to 'pages'. */ + pagesSlug?: string /** Archive-backed collections, same value as the plugin option. */ content?: ContentOption + /** SiteSettings global slug. Defaults to 'site-settings'. */ + settingsSlug?: string /** * 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 + changeFrequency?: SitemapEntry['changeFrequency'] } type DocRow = { - _status?: string - id: number | string - meta?: { noindex?: boolean } | null + id: string | number slug?: unknown updatedAt?: string + _status?: string + meta?: { noindex?: boolean } | null } -/** Skip drafts and anything flagged noindex in the SEO tab. */ +/** + * 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: 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)) { + 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 (doc._status && doc._status !== 'published') return false + if (doc.meta?.noindex) return false + if (hasExcludedSlug(doc.slug)) return false return true } @@ -77,17 +96,17 @@ function entryFor( homeSlug: string | undefined, prefix: Record | undefined, changeFrequency: SitemapEntry['changeFrequency'], -): null | SitemapEntry { +): SitemapEntry | null { const slugs = doc.slug && typeof doc.slug === 'object' - ? getLocalizedSlugs({ config, slugField: doc.slug as Record }) + ? getLocalizedSlugs({ slugField: doc.slug as Record, config }) : {} - const path = buildLocalizedPath({ config, homeSlug, locale, prefix, slugs }) - if (!path) {return null} + const path = buildLocalizedPath({ slugs, locale, config, homeSlug, prefix }) + if (!path) return null const origin = baseUrl.replace(/\/$/, '') - const languages = buildHreflangAlternates({ baseUrl, config, homeSlug, prefix, slugs }) + const languages = buildHreflangAlternates({ slugs, config, baseUrl, homeSlug, prefix }) return { url: `${origin}${path}`, @@ -119,14 +138,14 @@ function entryFor( * ``` */ export async function buildSitemapEntries({ - baseUrl, - changeFrequency = 'weekly', - config, - content, - homeSlug, - pagesSlug = 'pages', payload, + config, + baseUrl, + pagesSlug = 'pages', + content, settingsSlug = 'site-settings', + homeSlug, + changeFrequency = 'weekly', }: BuildSitemapArgs): Promise { const locales = config.locales.map((l) => l.code) const defaultLocale = config.defaultLocale @@ -135,8 +154,8 @@ export async function buildSitemapEntries({ // locales so archive prefixes are available per language). const settings = (await payload.findGlobal({ slug: settingsSlug as never, - depth: 1, locale: 'all' as never, + depth: 1, })) as Record const resolvedHomeSlug = @@ -159,14 +178,14 @@ export async function buildSitemapEntries({ // alternates without re-querying per locale. const result = await payload.find({ collection: collection.slug as never, + locale: 'all' 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} + if (!isIndexable(raw)) continue // Emit the entry under the default locale's URL; alternates cover the rest. const entry = entryFor( @@ -178,7 +197,7 @@ export async function buildSitemapEntries({ collection.prefixSlugs, changeFrequency, ) - if (entry) {entries.push(entry)} + if (entry) entries.push(entry) } } @@ -187,9 +206,9 @@ export async function buildSitemapEntries({ /** 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} + if (!rel || typeof rel !== 'object') return undefined const slug = (rel as { slug?: unknown }).slug - if (typeof slug === 'string') {return slug} + if (typeof slug === 'string') return slug if (slug && typeof slug === 'object') { const v = (slug as Record)[locale] return typeof v === 'string' ? v : undefined @@ -199,14 +218,14 @@ function extractSlugInLocale(rel: unknown, locale: string): string | undefined { /** Builds a locale→slug map from a populated archive relationship. */ function slugMapAllLocales(rel: unknown, locales: string[]): Record | undefined { - if (!rel || typeof rel !== 'object') {return undefined} + if (!rel || typeof rel !== 'object') return undefined const slug = (rel as { slug?: unknown }).slug - if (!slug || typeof slug !== 'object') {return undefined} + if (!slug || typeof slug !== 'object') return undefined const map: Record = {} for (const locale of locales) { const v = (slug as Record)[locale] - if (typeof v === 'string') {map[locale] = v} + if (typeof v === 'string') map[locale] = v } return Object.keys(map).length ? map : undefined }