178 lines
6.4 KiB
TypeScript
178 lines
6.4 KiB
TypeScript
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 type { I18nConfig } from '../i18n/index.js'
|
|
import type { SitemapEntry, RobotsRules } from '../seo/index.js'
|
|
import { buildSitemapEntries, buildRobots } from '../seo/index.js'
|
|
|
|
type CreateContentHelpersArgs = {
|
|
/**
|
|
* 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> | 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
|
|
}
|
|
|
|
/**
|
|
* 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({
|
|
config,
|
|
content,
|
|
settingsSlug = 'site-settings',
|
|
pagesSlug = 'pages',
|
|
i18n,
|
|
baseUrl,
|
|
}: CreateContentHelpersArgs) {
|
|
const origin = baseUrl ?? process.env.NEXT_PUBLIC_SERVER_URL ?? ''
|
|
const getCachedPayload = cache(async (): Promise<BasePayload> =>
|
|
getPayload({ config: await config }),
|
|
)
|
|
|
|
const getConfiguredLocales = cache(async (): Promise<string[]> => {
|
|
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, locale: locale as never, depth: 2 })
|
|
})
|
|
|
|
/** What does this URL point at? Routing only — no listing data. */
|
|
const resolveRoute = cache(
|
|
async (
|
|
locale: string,
|
|
segments: string[] | undefined,
|
|
page: number,
|
|
): Promise<ResolvedRoute | null> => {
|
|
const payload = await getCachedPayload()
|
|
return resolveRouteRaw({ payload, locale, segments, page, content, pagesSlug, 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<ArchiveEntries> => {
|
|
const payload = await getCachedPayload()
|
|
return getArchiveEntries({ payload, collection, locale, page, 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<SitemapEntry[]> => {
|
|
if (!i18n) {
|
|
throw new Error('[ipal] createContentHelpers: pass `i18n` to use the sitemap handler.')
|
|
}
|
|
try {
|
|
return await buildSitemapEntries({
|
|
payload: await getCachedPayload(),
|
|
config: i18n,
|
|
baseUrl: origin,
|
|
content,
|
|
pagesSlug,
|
|
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 })
|
|
|
|
return {
|
|
getCachedPayload,
|
|
getConfiguredLocales,
|
|
getSettings,
|
|
resolveRoute,
|
|
getEntries,
|
|
sitemap,
|
|
robots,
|
|
}
|
|
}
|