import type { BasePayload, SanitizedConfig } from 'payload' 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. */ 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'. */ settingsSlug?: string } /** * Bundles the per-request data helpers a frontend needs — the same cached * wrappers every project was writing by hand (getPayload, settings, locale * list, route resolution, archive entries). * * Everything is wrapped in React `cache()`, so within one request a value is * fetched once no matter how many times it's asked for — which matters because * Next runs generateMetadata and the page component separately, and both hit * these. Crucially the Payload instance is cached *here*, once, so every helper * shares it; that's why this is a factory and not loose functions importing a * shared module. * * ```ts * // src/lib/content.ts * import { createContentHelpers } from 'ipal-kit' * import config from '@/payload.config' * import { contentConfig } from '@/content.config' * * export const { getCachedPayload, getSettings, getConfiguredLocales, resolveRoute, getEntries } = * createContentHelpers({ config, content: contentConfig }) * ``` * * `resolveRoute` and `getEntries` are separate on purpose: routing decides what * a URL is, fetching gets the listing. Metadata generation needs the first and * 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 }), ) const getConfiguredLocales = cache(async (): Promise => { const c = await config return c.localization ? c.localization.locales.map((l) => l.code) : [] }) const getSettings = cache(async (locale: string) => { const payload = await getCachedPayload() return payload.findGlobal({ slug: settingsSlug as never, depth: 2, locale: locale as never }) }) /** What does this URL point at? Routing only — no listing data. */ const resolveRoute = cache( async ( locale: string, segments: string[] | undefined, page: number, ): Promise => { const payload = await getCachedPayload() return resolveRouteRaw({ content, locale, page, pagesSlug, payload, segments, settingsSlug }) }, ) /** One page of a collection's entries, for an archive listing. */ const getEntries = cache( async ( collection: string, locale: string, page: number, perPage: number, ): Promise => { const payload = await getCachedPayload() return getArchiveEntries({ collection, locale, page, payload, perPage }) }, ) /** * 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' * export const dynamic = 'force-dynamic' // generate at runtime, not build * ``` * * IMPORTANT — container deploys (Coolify/Docker/Railway/CI): Next treats * sitemap.ts as STATIC by default and prerenders it during `next build`, which * calls into Payload → the database. The build container usually has no access * to the internal Docker network, so the DB connection fails (ENOTFOUND) and * the build dies. Two defenses: * 1. `export const dynamic = 'force-dynamic'` in app/sitemap.ts — skips build * prerender, generates at runtime when the DB is reachable (recommended). * 2. This handler also catches DB errors and returns [] so that even without * (1) the build won't crash — it just ships an empty sitemap until the * next runtime regeneration. (1) is still preferred; (2) is a safety net. */ const sitemap = cache(async (): Promise => { if (!i18n) { throw new Error('[ipal] createContentHelpers: pass `i18n` to use the sitemap handler.') } try { return await buildSitemapEntries({ baseUrl: origin, config: i18n, content, pagesSlug, payload: await getCachedPayload(), settingsSlug, }) } catch (error) { // DB unreachable (typically a container build with no DB network) — return // an empty sitemap instead of failing the build. Runtime regeneration will // produce the real one once the DB is reachable. See dynamic='force-dynamic'. console.warn( '[ipal] sitemap: could not reach the database, returning empty entries ' + "(add `export const dynamic = 'force-dynamic'` to app/sitemap.ts to " + 'generate at runtime and avoid build-time DB access):', error, ) return [] } }) /** * 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 }) /** * 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 (crawlers miss it). Providing generateStaticParams * compiles routes as SSG (●) — the 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 and 404/500/system slugs * - KEEPS noindex pages (they must still render — noindex controls indexing, * not existence; skipping them would force dynamic rendering) * - localized slugs (string or per-locale map) both handled * - 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[] }> > => { try { 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) { // NO where:{_status} filter — collections without drafts enabled don't // register the _status field, and querying it throws // "path cannot be queried: _status". We filter drafts in memory below, // which is safe for every collection (with or without drafts). const result = await payload.find({ collection: pagesSlug as never, depth: 0, limit: 1000, locale: (locale ?? 'all') as never, }) for (const raw of result.docs as Array<{ _status?: string id: number | string meta?: { noindex?: boolean } | null slug?: unknown }>) { // Draft filter in memory (safe whether or not the collection has drafts). if (raw._status && raw._status !== 'published') {continue} // NOTE: unlike the sitemap, we do NOT skip meta.noindex here. A noindex // page (privacy, cookies, terms) still needs to render — users reach it // from the footer and crawlers read its . Pre-render // it as SSG so it's fast and its is complete; noindex controls // INDEXING, not whether the page exists. Skipping it would force dynamic // rendering (the very streaming problem we're avoiding). 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 } // Slug may be a plain string OR a localized map ({ pl: 'kontakt' }) when // read with locale:'all' or left unflattened. Handle both, or localized // pages get silently dropped. const rawSlug = raw.slug const slug = typeof rawSlug === 'string' ? rawSlug : rawSlug && typeof rawSlug === 'object' ? (((rawSlug as Record)[locale ?? ''] as string | undefined) ?? (Object.values(rawSlug as Record)[0] as string | undefined)) : undefined if (!slug || EXCLUDED.has(slug)) {continue} // Multi-level slugs ('atrakcje/telefon') → array segments. const segments = String(slug).split('/').filter(Boolean) params.push( singleLocale ? { slug: segments } : { slug: segments, locale: locale as string }, ) } } return params } catch (err) { // DB unreachable — typically a container build (Docker/Coolify/CI) with no // database network. Return [] so the build doesn't crash: Next falls back // to on-demand rendering for the routes, which fill in once the DB is // reachable at runtime. Without this every project would need its own // try/catch here. (Same graceful-degradation as the sitemap handler.) console.warn( '[ipal] generateStaticParams: database not reachable during build ' + '(Docker/CI) — returning empty params; routes render on-demand at runtime:', err, ) return [] } } return { generateStaticParams, getCachedPayload, getConfiguredLocales, getEntries, getSettings, resolveRoute, robots, sitemap, } }