Fixed support for multi-langual slugs
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
import type { BasePayload } from 'payload'
|
||||
import type { I18nConfig } 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 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`.
|
||||
@@ -15,44 +16,44 @@ import { archiveFieldName } from '../content/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<string, string> }
|
||||
changeFrequency?: 'always' | 'daily' | 'hourly' | 'monthly' | 'never' | 'weekly' | 'yearly'
|
||||
lastModified?: Date | string
|
||||
priority?: number
|
||||
url: string
|
||||
}
|
||||
|
||||
type CollectionEntry = {
|
||||
slug: string
|
||||
/** Localized segment for entries (archive page slugs), when applicable. */
|
||||
prefixSlugs?: Record<string, string>
|
||||
slug: string
|
||||
}
|
||||
|
||||
type BuildSitemapArgs = {
|
||||
payload: BasePayload
|
||||
config: I18nConfig
|
||||
/** Absolute origin, e.g. 'https://example.com'. Required for valid sitemap URLs. */
|
||||
baseUrl: string
|
||||
/** Pages collection slug. Defaults to 'pages'. */
|
||||
pagesSlug?: string
|
||||
changeFrequency?: SitemapEntry['changeFrequency']
|
||||
config: I18nConfig
|
||||
/** 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
|
||||
changeFrequency?: SitemapEntry['changeFrequency']
|
||||
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 = {
|
||||
id: string | number
|
||||
_status?: string
|
||||
id: number | string
|
||||
meta?: { noindex?: boolean } | null
|
||||
slug?: unknown
|
||||
updatedAt?: string
|
||||
_status?: string
|
||||
meta?: { noindex?: boolean } | null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -61,14 +62,14 @@ type DocRow = {
|
||||
* 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'])
|
||||
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 (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
|
||||
if (typeof value === 'string' && EXCLUDED_SITEMAP_SLUGS.has(value)) {return true}
|
||||
}
|
||||
}
|
||||
return false
|
||||
@@ -76,9 +77,9 @@ function hasExcludedSlug(slug: unknown): boolean {
|
||||
|
||||
/** 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
|
||||
if (doc._status && doc._status !== 'published') {return false}
|
||||
if (doc.meta?.noindex) {return false}
|
||||
if (hasExcludedSlug(doc.slug)) {return false}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -93,20 +94,20 @@ function entryFor(
|
||||
locale: string,
|
||||
config: I18nConfig,
|
||||
baseUrl: string,
|
||||
homeSlug: string | undefined,
|
||||
homeSlug: Record<string, string> | string | undefined,
|
||||
prefix: Record<string, string> | undefined,
|
||||
changeFrequency: SitemapEntry['changeFrequency'],
|
||||
): SitemapEntry | null {
|
||||
): null | SitemapEntry {
|
||||
const slugs =
|
||||
doc.slug && typeof doc.slug === 'object'
|
||||
? getLocalizedSlugs({ slugField: doc.slug as Record<string, unknown>, config })
|
||||
? getLocalizedSlugs({ config, slugField: doc.slug as Record<string, unknown> })
|
||||
: {}
|
||||
|
||||
const path = buildLocalizedPath({ slugs, locale, config, homeSlug, prefix })
|
||||
if (!path) return null
|
||||
const path = buildLocalizedPath({ config, homeSlug, locale, prefix, slugs })
|
||||
if (!path) {return null}
|
||||
|
||||
const origin = baseUrl.replace(/\/$/, '')
|
||||
const languages = buildHreflangAlternates({ slugs, config, baseUrl, homeSlug, prefix })
|
||||
const languages = buildHreflangAlternates({ baseUrl, config, homeSlug, prefix, slugs })
|
||||
|
||||
return {
|
||||
url: `${origin}${path}`,
|
||||
@@ -138,14 +139,14 @@ function entryFor(
|
||||
* ```
|
||||
*/
|
||||
export async function buildSitemapEntries({
|
||||
payload,
|
||||
config,
|
||||
baseUrl,
|
||||
pagesSlug = 'pages',
|
||||
content,
|
||||
settingsSlug = 'site-settings',
|
||||
homeSlug,
|
||||
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
|
||||
@@ -154,8 +155,8 @@ export async function buildSitemapEntries({
|
||||
// locales so archive prefixes are available per language).
|
||||
const settings = (await payload.findGlobal({
|
||||
slug: settingsSlug as never,
|
||||
locale: 'all' as never,
|
||||
depth: 1,
|
||||
locale: 'all' as never,
|
||||
})) as Record<string, unknown>
|
||||
|
||||
const resolvedHomeSlug =
|
||||
@@ -178,26 +179,32 @@ 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(
|
||||
raw,
|
||||
defaultLocale,
|
||||
config,
|
||||
baseUrl,
|
||||
resolvedHomeSlug,
|
||||
collection.prefixSlugs,
|
||||
changeFrequency,
|
||||
)
|
||||
if (entry) entries.push(entry)
|
||||
// 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)}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,9 +213,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<string, unknown>)[locale]
|
||||
return typeof v === 'string' ? v : undefined
|
||||
@@ -218,14 +225,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<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 (!slug || typeof slug !== 'object') return undefined
|
||||
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
|
||||
if (typeof v === 'string') {map[locale] = v}
|
||||
}
|
||||
return Object.keys(map).length ? map : undefined
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user