230 lines
10 KiB
JavaScript
230 lines
10 KiB
JavaScript
import { getPayload } from 'payload';
|
|
import { cache } from 'react';
|
|
import { getArchiveEntries, resolveRoute as resolveRouteRaw } from '../content/index.js';
|
|
import { buildRobots, buildSitemapEntries } from '../seo/index.js';
|
|
/**
|
|
* 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' }) {
|
|
const origin = baseUrl ?? process.env.NEXT_PUBLIC_SERVER_URL ?? '';
|
|
const getCachedPayload = cache(async ()=>getPayload({
|
|
config: await config
|
|
}));
|
|
const getConfiguredLocales = cache(async ()=>{
|
|
const c = await config;
|
|
return c.localization ? c.localization.locales.map((l)=>l.code) : [];
|
|
});
|
|
const getSettings = cache(async (locale)=>{
|
|
const payload = await getCachedPayload();
|
|
return payload.findGlobal({
|
|
slug: settingsSlug,
|
|
depth: 2,
|
|
locale: locale
|
|
});
|
|
});
|
|
/** What does this URL point at? Routing only — no listing data. */ const resolveRoute = cache(async (locale, segments, page)=>{
|
|
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, locale, page, perPage)=>{
|
|
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 ()=>{
|
|
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 = ()=>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 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 ()=>{
|
|
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,
|
|
depth: 1,
|
|
locale: 'all'
|
|
}).catch(()=>null);
|
|
const homeId = settings?.homepage?.id;
|
|
const EXCLUDED = new Set([
|
|
'404',
|
|
'500',
|
|
'error',
|
|
'not-found'
|
|
]);
|
|
const params = [];
|
|
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,
|
|
depth: 0,
|
|
limit: 1000,
|
|
locale: locale ?? 'all'
|
|
});
|
|
for (const raw of result.docs){
|
|
// 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 <meta robots=noindex>. Pre-render
|
|
// it as SSG so it's fast and its <head> 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
|
|
};
|
|
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[locale ?? ''] ?? Object.values(rawSlug)[0] : 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
|
|
});
|
|
}
|
|
}
|
|
return params;
|
|
};
|
|
return {
|
|
generateStaticParams,
|
|
getCachedPayload,
|
|
getConfiguredLocales,
|
|
getEntries,
|
|
getSettings,
|
|
resolveRoute,
|
|
robots,
|
|
sitemap
|
|
};
|
|
}
|
|
|
|
//# sourceMappingURL=createContentHelpers.js.map
|