SEO: multilingual homepage (homeSlug per locale in path/sitemap), Article + llms.txt generators, siteDescription fallback, composeTitle dedup
This commit is contained in:
@@ -1,14 +1,22 @@
|
||||
import { cache } from 'react'
|
||||
import type { BasePayload, SanitizedConfig } from 'payload'
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
import type { ContentOption, ResolvedRoute, ArchiveEntries } from '../content/index.js'
|
||||
import { resolveRoute as resolveRouteRaw, getArchiveEntries } from '../content/index.js'
|
||||
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 { SitemapEntry, RobotsRules } from '../seo/index.js'
|
||||
import { buildSitemapEntries, buildRobots } from '../seo/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.
|
||||
@@ -16,21 +24,15 @@ type CreateContentHelpersArgs = {
|
||||
config: Promise<SanitizedConfig> | SanitizedConfig
|
||||
/** Archive-backed collections, same value as the plugin option. */
|
||||
content?: ContentOption
|
||||
/** SiteSettings global slug. Defaults to 'site-settings'. */
|
||||
settingsSlug?: string
|
||||
/** Pages collection slug. Defaults to 'pages'. */
|
||||
pagesSlug?: string
|
||||
/**
|
||||
* i18n config. Required only if you want the ready-made `sitemap` / `robots`
|
||||
* handlers — they need the locale list to emit hreflang.
|
||||
*/
|
||||
i18n?: I18nConfig
|
||||
/**
|
||||
* 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
|
||||
/** Pages collection slug. Defaults to 'pages'. */
|
||||
pagesSlug?: string
|
||||
/** SiteSettings global slug. Defaults to 'site-settings'. */
|
||||
settingsSlug?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -60,12 +62,12 @@ type CreateContentHelpersArgs = {
|
||||
* not the second, and a page component composes them in two obvious lines.
|
||||
*/
|
||||
export function createContentHelpers({
|
||||
baseUrl,
|
||||
config,
|
||||
content,
|
||||
settingsSlug = 'site-settings',
|
||||
pagesSlug = 'pages',
|
||||
i18n,
|
||||
baseUrl,
|
||||
pagesSlug = 'pages',
|
||||
settingsSlug = 'site-settings',
|
||||
}: CreateContentHelpersArgs) {
|
||||
const origin = baseUrl ?? process.env.NEXT_PUBLIC_SERVER_URL ?? ''
|
||||
const getCachedPayload = cache(async (): Promise<BasePayload> =>
|
||||
@@ -79,7 +81,7 @@ export function createContentHelpers({
|
||||
|
||||
const getSettings = cache(async (locale: string) => {
|
||||
const payload = await getCachedPayload()
|
||||
return payload.findGlobal({ slug: settingsSlug as never, locale: locale as never, depth: 2 })
|
||||
return payload.findGlobal({ slug: settingsSlug as never, depth: 2, locale: locale as never })
|
||||
})
|
||||
|
||||
/** What does this URL point at? Routing only — no listing data. */
|
||||
@@ -88,9 +90,9 @@ export function createContentHelpers({
|
||||
locale: string,
|
||||
segments: string[] | undefined,
|
||||
page: number,
|
||||
): Promise<ResolvedRoute | null> => {
|
||||
): Promise<null | ResolvedRoute> => {
|
||||
const payload = await getCachedPayload()
|
||||
return resolveRouteRaw({ payload, locale, segments, page, content, pagesSlug, settingsSlug })
|
||||
return resolveRouteRaw({ content, locale, page, pagesSlug, payload, segments, settingsSlug })
|
||||
},
|
||||
)
|
||||
|
||||
@@ -103,7 +105,7 @@ export function createContentHelpers({
|
||||
perPage: number,
|
||||
): Promise<ArchiveEntries> => {
|
||||
const payload = await getCachedPayload()
|
||||
return getArchiveEntries({ payload, collection, locale, page, perPage })
|
||||
return getArchiveEntries({ collection, locale, page, payload, perPage })
|
||||
},
|
||||
)
|
||||
|
||||
@@ -134,11 +136,11 @@ export function createContentHelpers({
|
||||
}
|
||||
try {
|
||||
return await buildSitemapEntries({
|
||||
payload: await getCachedPayload(),
|
||||
config: i18n,
|
||||
baseUrl: origin,
|
||||
config: i18n,
|
||||
content,
|
||||
pagesSlug,
|
||||
payload: await getCachedPayload(),
|
||||
settingsSlug,
|
||||
})
|
||||
} catch (error) {
|
||||
@@ -165,13 +167,89 @@ export function createContentHelpers({
|
||||
*/
|
||||
const robots = (): RobotsRules => buildRobots({ baseUrl: origin })
|
||||
|
||||
/**
|
||||
* Next.js generateStaticParams for the [[...slug]] route (or
|
||||
* [locale]/[[...slug]]). Returns every routable page as a params object, so
|
||||
* Next PRE-RENDERS them as static (SSG) instead of dynamic.
|
||||
*
|
||||
* Why this matters beyond convenience: an optional catch-all with no
|
||||
* generateStaticParams is treated as a DYNAMIC route (ƒ), which streams
|
||||
* metadata into <body> (crawlers miss it). Providing generateStaticParams
|
||||
* compiles routes as SSG (●) — the <head> is synchronous and complete. This is
|
||||
* the strongest fix for the metadata-in-head problem (stronger than ISR alone).
|
||||
*
|
||||
* Handles automatically:
|
||||
* - pages collection + content collections (with their archive prefix)
|
||||
* - excludes the homepage (maps to { slug: [] } — the root)
|
||||
* - excludes drafts, 404/500/system slugs, and meta.noindex docs
|
||||
* - single-locale → { slug }[]; multi-locale → { locale, slug }[]
|
||||
*
|
||||
* Wire it in the project:
|
||||
* // app/(frontend)/[[...slug]]/page.tsx (or [locale]/[[...slug]])
|
||||
* export { generateStaticParams } from '@/lib/content'
|
||||
*/
|
||||
const generateStaticParams = async (): Promise<
|
||||
Array<{ locale: string; slug: string[] } | { slug: string[] }>
|
||||
> => {
|
||||
const payload = await getCachedPayload()
|
||||
const locales = i18n ? i18n.locales.map((l) => l.code) : [undefined]
|
||||
const singleLocale = !i18n || i18n.locales.length === 1
|
||||
|
||||
// Home slug per locale, to exclude the homepage (it's the root, slug []).
|
||||
const settings = (await payload
|
||||
.findGlobal({ slug: settingsSlug as never, depth: 1, locale: 'all' as never })
|
||||
.catch(() => null)) as { homepage?: { id?: number | string; slug?: unknown } } | null
|
||||
const homeId = settings?.homepage?.id
|
||||
|
||||
const EXCLUDED = new Set(['404', '500', 'error', 'not-found'])
|
||||
const params: Array<{ locale: string; slug: string[] } | { slug: string[] }> = []
|
||||
|
||||
for (const locale of locales) {
|
||||
const result = await payload.find({
|
||||
collection: pagesSlug as never,
|
||||
depth: 0,
|
||||
limit: 1000,
|
||||
locale: (locale ?? 'all') as never,
|
||||
where: { _status: { not_equals: 'draft' } } as never,
|
||||
})
|
||||
|
||||
for (const raw of result.docs as Array<{
|
||||
_status?: string
|
||||
id: number | string
|
||||
meta?: { noindex?: boolean } | null
|
||||
slug?: unknown
|
||||
}>) {
|
||||
if (raw._status && raw._status !== 'published') {continue}
|
||||
if (raw.meta?.noindex) {continue}
|
||||
if (homeId && raw.id === homeId) {
|
||||
// Homepage → root. Emit an empty-slug param so '/' (or '/pl') builds.
|
||||
const empty = singleLocale ? { slug: [] } : { slug: [], locale: locale as string }
|
||||
if (!params.some((p) => JSON.stringify(p) === JSON.stringify(empty))) {params.push(empty)}
|
||||
continue
|
||||
}
|
||||
|
||||
const slug = typeof raw.slug === 'string' ? raw.slug : undefined
|
||||
if (!slug || EXCLUDED.has(slug)) {continue}
|
||||
|
||||
// Multi-level slugs ('atrakcje/telefon') → array segments.
|
||||
const segments = slug.split('/').filter(Boolean)
|
||||
params.push(
|
||||
singleLocale ? { slug: segments } : { slug: segments, locale: locale as string },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
return {
|
||||
generateStaticParams,
|
||||
getCachedPayload,
|
||||
getConfiguredLocales,
|
||||
getEntries,
|
||||
getSettings,
|
||||
resolveRoute,
|
||||
getEntries,
|
||||
sitemap,
|
||||
robots,
|
||||
sitemap,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user