diff --git a/docs/getting-started.md b/docs/getting-started.md index 7aa1538..4e6d4ec 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -453,6 +453,20 @@ rm -f *.db *.db-shm *.db-wal && pnpm generate:types && pnpm dev Adres wpisów = slug strony-archiwum. Zmiana tytułu strony przenosi sekcję. Kolejny typ treści (realizacje) = kolejna kolekcja + kolejna pozycja w content.config. +### Sitemapa i robots.txt + +`createContentHelpers` oddaje gotowe handlery — dodaj `i18n` i `baseUrl` do jego +argumentów (patrz seo.md), potem dwa pliki po jednej linii: + +```ts +// app/sitemap.ts +export { sitemap as default } from '@/lib/content' +// app/robots.ts +export { robots as default } from '@/lib/content' +``` + +Sitemapa z hreflangiem per URL, lastmod, wpisami bloga; pomija drafty i noindex. + ### Analytics **Site Integrations** → GA4 Measurement ID albo GTM Container ID. Tagi ładują diff --git a/docs/seo.md b/docs/seo.md index 0f0a9bc..258085a 100644 --- a/docs/seo.md +++ b/docs/seo.md @@ -196,4 +196,55 @@ const slugs = await slugsAcrossLocales({ payload, collection: 'pages', id, confi `slugsAcrossLocales` robi osobne zapytanie z `locale: 'all'` — bo ten tryb zamienia KAŻDE zlokalizowane pole w mapę, co jest dobre dla sluga i złe dla reszty (mapa w title rozłożyłaby składanie tytułu). Dlatego czyta tylko slug, -depth 0. \ No newline at end of file +depth 0. + +## Sitemapa i robots.txt + +Plugin składa sitemapę z hreflangiem per URL — te same buildery co metadane, więc +nie rozjedzie się z tym, co strony serwują. Handlery są gotowe w +`createContentHelpers`; klient re-eksportuje je jedną linią. + +```ts +// src/lib/content.ts +export const { /* ... */, sitemap, robots } = createContentHelpers({ + config, + content: contentConfig, + i18n: i18nConfig, // wymagane dla sitemap (hreflang) + baseUrl: process.env.NEXT_PUBLIC_SERVER_URL, +}) +``` + +```ts +// app/sitemap.ts +export { sitemap as default } from '@/lib/content' + +// app/robots.ts +export { robots as default } from '@/lib/content' +``` + +Next generuje z tego `/sitemap.xml` i `/robots.txt`. Nie da się tego schować +całkiem w pluginie — Next tworzy te trasy wyłącznie z plików w `app/`, skanuje +katalog projektu, nie node_modules. Ale re-eksport to maksimum redukcji: cała +logika jest w pluginie. + +Co zawiera sitemapa: +- każdą stronę i wpis bloga, URL w domyślnym locale +- `alternates.languages` → Next renderuje `` + per URL (to czyta Google przy wielu językach; zwykła sitemapa tego nie ma) +- `lastModified` z realnego `updatedAt` dokumentu +- wpisy bloga z prefiksem archiwum (`/pl/artykuly/moj-post`) + +Pomija: drafty (`_status !== 'published'`) i dokumenty z `meta.noindex`. + +Niskopoziomowo (własna trasa zamiast handlera z fabryki): + +```ts +import { buildSitemapEntries, buildRobots } from 'ipal-kit' + +const entries = await buildSitemapEntries({ + payload, config: i18nConfig, baseUrl, content: contentConfig, +}) +``` + +Przy dziesiątkach tysięcy URL-i Next ma `generateSitemaps` do dzielenia na +części — dodasz, gdyby realnie było ich tyle. Jedna sitemapa wystarcza do ~50k. \ No newline at end of file diff --git a/package.json b/package.json index 5559dbd..4048385 100644 --- a/package.json +++ b/package.json @@ -149,4 +149,4 @@ "unrs-resolver" ] } -} +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4335a1a..55c4445 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,12 +8,9 @@ importers: .: dependencies: - '@payloadcms/plugin-form-builder': - specifier: 3.84.1 - version: 3.84.1(@types/react@19.2.14)(monaco-editor@0.55.1)(next@16.2.6(@babel/core@7.29.7)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(sass@1.77.4))(payload@3.84.1(graphql@16.14.2)(typescript@5.7.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.7.3) - '@payloadcms/plugin-seo': - specifier: 3.84.1 - version: 3.84.1(@types/react@19.2.14)(monaco-editor@0.55.1)(next@16.2.6(@babel/core@7.29.7)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(sass@1.77.4))(payload@3.84.1(graphql@16.14.2)(typescript@5.7.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.7.3) + '@intecion/ipal-kit': + specifier: file:intecion-ipal-kit-1.0.0.tgz + version: file:intecion-ipal-kit-1.0.0.tgz(@payloadcms/plugin-form-builder@3.84.1(@types/react@19.2.14)(monaco-editor@0.55.1)(next@16.2.6(@babel/core@7.29.7)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(sass@1.77.4))(payload@3.84.1(graphql@16.14.2)(typescript@5.7.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.7.3))(@payloadcms/plugin-seo@3.84.1(@types/react@19.2.14)(monaco-editor@0.55.1)(next@16.2.6(@babel/core@7.29.7)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(sass@1.77.4))(payload@3.84.1(graphql@16.14.2)(typescript@5.7.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.7.3))(next@16.2.6(@babel/core@7.29.7)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(sass@1.77.4))(payload@3.84.1(graphql@16.14.2)(typescript@5.7.3))(react@19.2.6) lucide-react: specifier: ^0.400.0 version: 0.400.0(react@19.2.6) @@ -42,6 +39,12 @@ importers: '@payloadcms/next': specifier: 3.84.1 version: 3.84.1(@types/react@19.2.14)(graphql@16.14.2)(monaco-editor@0.55.1)(next@16.2.6(@babel/core@7.29.7)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(sass@1.77.4))(payload@3.84.1(graphql@16.14.2)(typescript@5.7.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.7.3) + '@payloadcms/plugin-form-builder': + specifier: 3.84.1 + version: 3.84.1(@types/react@19.2.14)(monaco-editor@0.55.1)(next@16.2.6(@babel/core@7.29.7)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(sass@1.77.4))(payload@3.84.1(graphql@16.14.2)(typescript@5.7.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.7.3) + '@payloadcms/plugin-seo': + specifier: 3.84.1 + version: 3.84.1(@types/react@19.2.14)(monaco-editor@0.55.1)(next@16.2.6(@babel/core@7.29.7)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(sass@1.77.4))(payload@3.84.1(graphql@16.14.2)(typescript@5.7.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.7.3) '@payloadcms/richtext-lexical': specifier: 3.84.1 version: 3.84.1(@faceless-ui/modal@3.0.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@faceless-ui/scroll-info@2.0.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@payloadcms/next@3.84.1(@types/react@19.2.14)(graphql@16.14.2)(monaco-editor@0.55.1)(next@16.2.6(@babel/core@7.29.7)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(sass@1.77.4))(payload@3.84.1(graphql@16.14.2)(typescript@5.7.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.7.3))(@types/react@19.2.14)(monaco-editor@0.55.1)(next@16.2.6(@babel/core@7.29.7)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(sass@1.77.4))(payload@3.84.1(graphql@16.14.2)(typescript@5.7.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.7.3)(yjs@13.6.31) @@ -1345,6 +1348,17 @@ packages: cpu: [x64] os: [win32] + '@intecion/ipal-kit@file:intecion-ipal-kit-1.0.0.tgz': + resolution: {integrity: sha512-4nKuQXeg6fZ/5fMmf4145sDx006j/KpVIMywGv7CTqqRLrZV1R6oIg+8XwFcodN5RXP+ksMwAW8bdizLc/Cdqg==, tarball: file:intecion-ipal-kit-1.0.0.tgz} + version: 1.0.0 + engines: {node: ^18.20.2 || >=20.9.0, pnpm: ^9 || ^10 || ^11} + peerDependencies: + '@payloadcms/plugin-form-builder': ^3.84.1 + '@payloadcms/plugin-seo': ^3.84.1 + next: '>=15' + payload: ^3.84.1 + react: ^19.0.0 + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -6603,6 +6617,18 @@ snapshots: '@img/sharp-win32-x64@0.34.5': optional: true + '@intecion/ipal-kit@file:intecion-ipal-kit-1.0.0.tgz(@payloadcms/plugin-form-builder@3.84.1(@types/react@19.2.14)(monaco-editor@0.55.1)(next@16.2.6(@babel/core@7.29.7)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(sass@1.77.4))(payload@3.84.1(graphql@16.14.2)(typescript@5.7.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.7.3))(@payloadcms/plugin-seo@3.84.1(@types/react@19.2.14)(monaco-editor@0.55.1)(next@16.2.6(@babel/core@7.29.7)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(sass@1.77.4))(payload@3.84.1(graphql@16.14.2)(typescript@5.7.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.7.3))(next@16.2.6(@babel/core@7.29.7)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(sass@1.77.4))(payload@3.84.1(graphql@16.14.2)(typescript@5.7.3))(react@19.2.6)': + dependencies: + '@payloadcms/plugin-form-builder': 3.84.1(@types/react@19.2.14)(monaco-editor@0.55.1)(next@16.2.6(@babel/core@7.29.7)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(sass@1.77.4))(payload@3.84.1(graphql@16.14.2)(typescript@5.7.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.7.3) + '@payloadcms/plugin-seo': 3.84.1(@types/react@19.2.14)(monaco-editor@0.55.1)(next@16.2.6(@babel/core@7.29.7)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(sass@1.77.4))(payload@3.84.1(graphql@16.14.2)(typescript@5.7.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.7.3) + lucide-react: 0.400.0(react@19.2.6) + next: 16.2.6(@babel/core@7.29.7)(@playwright/test@1.58.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(sass@1.77.4) + nodemailer: 8.0.11 + payload: 3.84.1(graphql@16.14.2)(typescript@5.7.3) + react: 19.2.6 + server-only: 0.0.1 + slugify: 1.6.9 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 diff --git a/src/exports/next-middleware.ts b/src/exports/next-middleware.ts index e506a84..ec33c56 100644 --- a/src/exports/next-middleware.ts +++ b/src/exports/next-middleware.ts @@ -2,7 +2,7 @@ * Entry point: ipal-kit/next/middleware * * Re-exports the locale middleware factory and default matcher for use in a - * client project's next-middleware.ts. Kept as a thin re-export so the Next-facing + * client project's middleware.ts. Kept as a thin re-export so the Next-facing * surface is separate from the main package export. */ export { createLocaleMiddleware, DEFAULT_MIDDLEWARE_MATCHER } from '../modules/i18n/index.js' diff --git a/src/index.ts b/src/index.ts index 004bd29..ee97dab 100644 --- a/src/index.ts +++ b/src/index.ts @@ -83,13 +83,15 @@ export { } from './modules/payload/index.js' export type { PageMetadata, SeoMeta, SeoOption } from './modules/seo/index.js' export { buildHreflangAlternates, buildMetadata, composeTitle } from './modules/seo/index.js' -export type { AutoFillMapping } from './modules/seo/index.js' +export type { AutoFillMapping, RobotsRules, SitemapEntry } from './modules/seo/index.js' export { buildAutoFillMetaHook, + buildRobots, + buildSitemapEntries, createMetadataGenerator, createPageMetadata, injectAutoFillMeta, } from './modules/seo/index.js' export { buildSlugField, toSlug } from './modules/slug/index.js' -export { default as ipalKit } from './plugin.js' +export { ipalKit } from './plugin.js' export type { IpalOptions } from './types.js' diff --git a/src/modules/frontend/createContentHelpers.ts b/src/modules/frontend/createContentHelpers.ts index f86d292..882936f 100644 --- a/src/modules/frontend/createContentHelpers.ts +++ b/src/modules/frontend/createContentHelpers.ts @@ -4,10 +4,19 @@ import { getPayload } from 'payload' import { cache } from 'react' import type { ArchiveEntries, ContentOption, ResolvedRoute } from '../content/index.js' +import type { I18nConfig } from '../i18n/index.js' +import type { RobotsRules, SitemapEntry } from '../seo/index.js' import { getArchiveEntries, resolveRoute as resolveRouteRaw } from '../content/index.js' +import { buildRobots, buildSitemapEntries } from '../seo/index.js' type CreateContentHelpersArgs = { + /** + * Absolute site origin for sitemap/robots URLs. Falls back to + * NEXT_PUBLIC_SERVER_URL, then to a relative origin (which most crawlers + * reject, so set one in production). + */ + baseUrl?: string /** * The client's payload config promise (the default export of payload.config). * Passed in because the plugin never imports the client's config directly. @@ -15,6 +24,11 @@ type CreateContentHelpersArgs = { config: Promise | SanitizedConfig /** Archive-backed collections, same value as the plugin option. */ content?: ContentOption + /** + * i18n config. Required only if you want the ready-made `sitemap` / `robots` + * handlers — they need the locale list to emit hreflang. + */ + i18n?: I18nConfig /** Pages collection slug. Defaults to 'pages'. */ pagesSlug?: string /** SiteSettings global slug. Defaults to 'site-settings'. */ @@ -48,11 +62,14 @@ type CreateContentHelpersArgs = { * not the second, and a page component composes them in two obvious lines. */ export function createContentHelpers({ + baseUrl, config, content, + i18n, pagesSlug = 'pages', settingsSlug = 'site-settings', }: CreateContentHelpersArgs) { + const origin = baseUrl ?? process.env.NEXT_PUBLIC_SERVER_URL ?? '' const getCachedPayload = cache(async (): Promise => getPayload({ config: await config }), ) @@ -92,5 +109,46 @@ export function createContentHelpers({ }, ) - return { getCachedPayload, getConfiguredLocales, getEntries, getSettings, resolveRoute } + /** + * Ready-made handler for Next's `app/sitemap.ts` — every page and entry with + * per-URL hreflang and lastmod. Re-export it directly: + * + * ```ts + * // app/sitemap.ts + * export { sitemap as default } from '@/lib/content' + * ``` + */ + const sitemap = cache(async (): Promise => { + if (!i18n) { + throw new Error('[ipal] createContentHelpers: pass `i18n` to use the sitemap handler.') + } + return buildSitemapEntries({ + baseUrl: origin, + config: i18n, + content, + pagesSlug, + payload: await getCachedPayload(), + settingsSlug, + }) + }) + + /** + * Ready-made handler for Next's `app/robots.ts`. Re-export directly: + * + * ```ts + * // app/robots.ts + * export { robots as default } from '@/lib/content' + * ``` + */ + const robots = (): RobotsRules => buildRobots({ baseUrl: origin }) + + return { + getCachedPayload, + getConfiguredLocales, + getEntries, + getSettings, + resolveRoute, + robots, + sitemap, + } } diff --git a/src/modules/seo/buildRobots.ts b/src/modules/seo/buildRobots.ts new file mode 100644 index 0000000..ffd3f54 --- /dev/null +++ b/src/modules/seo/buildRobots.ts @@ -0,0 +1,49 @@ +type BuildRobotsArgs = { + /** Absolute origin, e.g. 'https://example.com'. */ + baseUrl: string + /** Paths to disallow. Defaults to Payload's admin and API. */ + disallow?: string[] + /** Whether to advertise the sitemap. Defaults to true. */ + sitemap?: boolean +} + +/** + * Shape for Next's `app/robots.ts`. + */ +export type RobotsRules = { + host?: string + rules: { allow?: string; disallow?: string[]; userAgent: string } + sitemap?: string +} + +/** + * Builds robots rules that allow crawling everything except the admin panel and + * API, and point crawlers at the sitemap. + * + * ```ts + * // app/robots.ts + * export default function robots() { + * return buildRobots({ baseUrl: process.env.NEXT_PUBLIC_SERVER_URL! }) + * } + * ``` + * + * The admin and API are disallowed by default — no reason for them in the index, + * and it keeps crawl budget on real content. + */ +export function buildRobots({ + baseUrl, + disallow = ['/admin', '/api'], + sitemap = true, +}: BuildRobotsArgs): RobotsRules { + const origin = baseUrl.replace(/\/$/, '') + + return { + rules: { + allow: '/', + disallow, + userAgent: '*', + }, + ...(sitemap ? { sitemap: `${origin}/sitemap.xml` } : {}), + host: origin, + } +} diff --git a/src/modules/seo/buildSitemapEntries.ts b/src/modules/seo/buildSitemapEntries.ts new file mode 100644 index 0000000..27bc8c9 --- /dev/null +++ b/src/modules/seo/buildSitemapEntries.ts @@ -0,0 +1,212 @@ +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 { 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 `` 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 } + 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 + 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?: 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 +} + +/** Skip drafts and anything flagged noindex in the SEO tab. */ +function isIndexable(doc: DocRow): boolean { + if (doc._status && doc._status !== 'published') {return false} + if (doc.meta?.noindex) {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: string | undefined, + prefix: Record | undefined, + changeFrequency: SitemapEntry['changeFrequency'], +): null | SitemapEntry { + const slugs = + doc.slug && typeof doc.slug === 'object' + ? getLocalizedSlugs({ config, slugField: doc.slug as Record }) + : {} + + 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 { + 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 + + 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: 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 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: 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)[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 | 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 = {} + for (const locale of locales) { + const v = (slug as Record)[locale] + if (typeof v === 'string') {map[locale] = v} + } + return Object.keys(map).length ? map : undefined +} diff --git a/src/modules/seo/index.ts b/src/modules/seo/index.ts index adc1e32..7f3e8a8 100644 --- a/src/modules/seo/index.ts +++ b/src/modules/seo/index.ts @@ -2,6 +2,10 @@ export { buildAutoFillMetaHook } from './autoFillMeta.js' export type { AutoFillMapping } from './autoFillMeta.js' export { buildMetadata } from './buildMetadata.js' export type { PageMetadata } from './buildMetadata.js' +export { buildRobots } from './buildRobots.js' +export type { RobotsRules } from './buildRobots.js' +export { buildSitemapEntries } from './buildSitemapEntries.js' +export type { SitemapEntry } from './buildSitemapEntries.js' export { composeTitle } from './composeTitle.js' export type { TitleOrder } from './composeTitle.js' export { createMetadataGenerator } from './createMetadataGenerator.js'