diff --git a/dist/modules/frontend/createContentHelpers.js b/dist/modules/frontend/createContentHelpers.js index e599fe1..ad23f9f 100644 --- a/dist/modules/frontend/createContentHelpers.js +++ b/dist/modules/frontend/createContentHelpers.js @@ -141,79 +141,89 @@ import { buildRobots, buildSitemapEntries } from '../seo/index.js'; * // 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 . 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 - }; - 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 + 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, + 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 . 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 + }; + 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; + } 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 params; }; return { generateStaticParams, diff --git a/dist/modules/frontend/createContentHelpers.js.map b/dist/modules/frontend/createContentHelpers.js.map index 847b783..032e034 100644 --- a/dist/modules/frontend/createContentHelpers.js.map +++ b/dist/modules/frontend/createContentHelpers.js.map @@ -1 +1 @@ -{"version":3,"sources":["../../../src/modules/frontend/createContentHelpers.ts"],"sourcesContent":["import type { BasePayload, SanitizedConfig } from 'payload'\n\nimport { getPayload } from 'payload'\nimport { cache } from 'react'\n\nimport type { ArchiveEntries, ContentOption, ResolvedRoute } from '../content/index.js'\nimport type { I18nConfig } from '../i18n/index.js'\nimport type { RobotsRules, SitemapEntry } from '../seo/index.js'\n\nimport { getArchiveEntries, resolveRoute as resolveRouteRaw } from '../content/index.js'\nimport { buildRobots, buildSitemapEntries } from '../seo/index.js'\n\ntype CreateContentHelpersArgs = {\n /**\n * Absolute site origin for sitemap/robots URLs. Falls back to\n * NEXT_PUBLIC_SERVER_URL, then to a relative origin (which most crawlers\n * reject, so set one in production).\n */\n baseUrl?: string\n /**\n * The client's payload config promise (the default export of payload.config).\n * Passed in because the plugin never imports the client's config directly.\n */\n config: Promise | SanitizedConfig\n /** Archive-backed collections, same value as the plugin option. */\n content?: ContentOption\n /**\n * i18n config. Required only if you want the ready-made `sitemap` / `robots`\n * handlers — they need the locale list to emit hreflang.\n */\n i18n?: I18nConfig\n /** Pages collection slug. Defaults to 'pages'. */\n pagesSlug?: string\n /** SiteSettings global slug. Defaults to 'site-settings'. */\n settingsSlug?: string\n}\n\n/**\n * Bundles the per-request data helpers a frontend needs — the same cached\n * wrappers every project was writing by hand (getPayload, settings, locale\n * list, route resolution, archive entries).\n *\n * Everything is wrapped in React `cache()`, so within one request a value is\n * fetched once no matter how many times it's asked for — which matters because\n * Next runs generateMetadata and the page component separately, and both hit\n * these. Crucially the Payload instance is cached *here*, once, so every helper\n * shares it; that's why this is a factory and not loose functions importing a\n * shared module.\n *\n * ```ts\n * // src/lib/content.ts\n * import { createContentHelpers } from 'ipal-kit'\n * import config from '@/payload.config'\n * import { contentConfig } from '@/content.config'\n *\n * export const { getCachedPayload, getSettings, getConfiguredLocales, resolveRoute, getEntries } =\n * createContentHelpers({ config, content: contentConfig })\n * ```\n *\n * `resolveRoute` and `getEntries` are separate on purpose: routing decides what\n * a URL is, fetching gets the listing. Metadata generation needs the first and\n * not the second, and a page component composes them in two obvious lines.\n */\nexport function createContentHelpers({\n baseUrl,\n config,\n content,\n i18n,\n pagesSlug = 'pages',\n settingsSlug = 'site-settings',\n}: CreateContentHelpersArgs) {\n const origin = baseUrl ?? process.env.NEXT_PUBLIC_SERVER_URL ?? ''\n const getCachedPayload = cache(async (): Promise =>\n getPayload({ config: await config }),\n )\n\n const getConfiguredLocales = cache(async (): Promise => {\n const c = await config\n return c.localization ? c.localization.locales.map((l) => l.code) : []\n })\n\n const getSettings = cache(async (locale: string) => {\n const payload = await getCachedPayload()\n return payload.findGlobal({ slug: settingsSlug as never, depth: 2, locale: locale as never })\n })\n\n /** What does this URL point at? Routing only — no listing data. */\n const resolveRoute = cache(\n async (\n locale: string,\n segments: string[] | undefined,\n page: number,\n ): Promise => {\n const payload = await getCachedPayload()\n return resolveRouteRaw({ content, locale, page, pagesSlug, payload, segments, settingsSlug })\n },\n )\n\n /** One page of a collection's entries, for an archive listing. */\n const getEntries = cache(\n async (\n collection: string,\n locale: string,\n page: number,\n perPage: number,\n ): Promise => {\n const payload = await getCachedPayload()\n return getArchiveEntries({ collection, locale, page, payload, perPage })\n },\n )\n\n /**\n * Ready-made handler for Next's `app/sitemap.ts` — every page and entry with\n * per-URL hreflang and lastmod. Re-export it directly:\n *\n * ```ts\n * // app/sitemap.ts\n * export { sitemap as default } from '@/lib/content'\n * export const dynamic = 'force-dynamic' // generate at runtime, not build\n * ```\n *\n * IMPORTANT — container deploys (Coolify/Docker/Railway/CI): Next treats\n * sitemap.ts as STATIC by default and prerenders it during `next build`, which\n * calls into Payload → the database. The build container usually has no access\n * to the internal Docker network, so the DB connection fails (ENOTFOUND) and\n * the build dies. Two defenses:\n * 1. `export const dynamic = 'force-dynamic'` in app/sitemap.ts — skips build\n * prerender, generates at runtime when the DB is reachable (recommended).\n * 2. This handler also catches DB errors and returns [] so that even without\n * (1) the build won't crash — it just ships an empty sitemap until the\n * next runtime regeneration. (1) is still preferred; (2) is a safety net.\n */\n const sitemap = cache(async (): Promise => {\n if (!i18n) {\n throw new Error('[ipal] createContentHelpers: pass `i18n` to use the sitemap handler.')\n }\n try {\n return await buildSitemapEntries({\n baseUrl: origin,\n config: i18n,\n content,\n pagesSlug,\n payload: await getCachedPayload(),\n settingsSlug,\n })\n } catch (error) {\n // DB unreachable (typically a container build with no DB network) — return\n // an empty sitemap instead of failing the build. Runtime regeneration will\n // produce the real one once the DB is reachable. See dynamic='force-dynamic'.\n console.warn(\n '[ipal] sitemap: could not reach the database, returning empty entries ' +\n \"(add `export const dynamic = 'force-dynamic'` to app/sitemap.ts to \" +\n 'generate at runtime and avoid build-time DB access):',\n error,\n )\n return []\n }\n })\n\n /**\n * Ready-made handler for Next's `app/robots.ts`. Re-export directly:\n *\n * ```ts\n * // app/robots.ts\n * export { robots as default } from '@/lib/content'\n * ```\n */\n const robots = (): RobotsRules => buildRobots({ baseUrl: origin })\n\n /**\n * Next.js generateStaticParams for the [[...slug]] route (or\n * [locale]/[[...slug]]). Returns every routable page as a params object, so\n * Next PRE-RENDERS them as static (SSG) instead of dynamic.\n *\n * Why this matters beyond convenience: an optional catch-all with no\n * generateStaticParams is treated as a DYNAMIC route (ƒ), which streams\n * metadata into (crawlers miss it). Providing generateStaticParams\n * compiles routes as SSG (●) — the is synchronous and complete. This is\n * the strongest fix for the metadata-in-head problem (stronger than ISR alone).\n *\n * Handles automatically:\n * - pages collection + content collections (with their archive prefix)\n * - excludes the homepage (maps to { slug: [] } — the root)\n * - excludes drafts and 404/500/system slugs\n * - KEEPS noindex pages (they must still render — noindex controls indexing,\n * not existence; skipping them would force dynamic rendering)\n * - localized slugs (string or per-locale map) both handled\n * - single-locale → { slug }[]; multi-locale → { locale, slug }[]\n *\n * Wire it in the project:\n * // app/(frontend)/[[...slug]]/page.tsx (or [locale]/[[...slug]])\n * export { generateStaticParams } from '@/lib/content'\n */\n const generateStaticParams = async (): Promise<\n Array<{ locale: string; slug: string[] } | { slug: string[] }>\n > => {\n const payload = await getCachedPayload()\n const locales = i18n ? i18n.locales.map((l) => l.code) : [undefined]\n const singleLocale = !i18n || i18n.locales.length === 1\n\n // Home slug per locale, to exclude the homepage (it's the root, slug []).\n const settings = (await payload\n .findGlobal({ slug: settingsSlug as never, depth: 1, locale: 'all' as never })\n .catch(() => null)) as { homepage?: { id?: number | string; slug?: unknown } } | null\n const homeId = settings?.homepage?.id\n\n const EXCLUDED = new Set(['404', '500', 'error', 'not-found'])\n const params: Array<{ locale: string; slug: string[] } | { slug: string[] }> = []\n\n for (const locale of locales) {\n // NO where:{_status} filter — collections without drafts enabled don't\n // register the _status field, and querying it throws\n // \"path cannot be queried: _status\". We filter drafts in memory below,\n // which is safe for every collection (with or without drafts).\n const result = await payload.find({\n collection: pagesSlug as never,\n depth: 0,\n limit: 1000,\n locale: (locale ?? 'all') as never,\n })\n\n for (const raw of result.docs as Array<{\n _status?: string\n id: number | string\n meta?: { noindex?: boolean } | null\n slug?: unknown\n }>) {\n // Draft filter in memory (safe whether or not the collection has drafts).\n if (raw._status && raw._status !== 'published') {continue}\n\n // NOTE: unlike the sitemap, we do NOT skip meta.noindex here. A noindex\n // page (privacy, cookies, terms) still needs to render — users reach it\n // from the footer and crawlers read its . Pre-render\n // it as SSG so it's fast and its is complete; noindex controls\n // INDEXING, not whether the page exists. Skipping it would force dynamic\n // rendering (the very streaming problem we're avoiding).\n\n if (homeId && raw.id === homeId) {\n // Homepage → root. Emit an empty-slug param so '/' (or '/pl') builds.\n const empty = singleLocale ? { slug: [] } : { slug: [], locale: locale as string }\n if (!params.some((p) => JSON.stringify(p) === JSON.stringify(empty))) {params.push(empty)}\n continue\n }\n\n // Slug may be a plain string OR a localized map ({ pl: 'kontakt' }) when\n // read with locale:'all' or left unflattened. Handle both, or localized\n // pages get silently dropped.\n const rawSlug = raw.slug\n const slug =\n typeof rawSlug === 'string'\n ? rawSlug\n : rawSlug && typeof rawSlug === 'object'\n ? (((rawSlug as Record)[locale ?? ''] as string | undefined) ??\n (Object.values(rawSlug as Record)[0] as string | undefined))\n : undefined\n\n if (!slug || EXCLUDED.has(slug)) {continue}\n\n // Multi-level slugs ('atrakcje/telefon') → array segments.\n const segments = String(slug).split('/').filter(Boolean)\n params.push(\n singleLocale ? { slug: segments } : { slug: segments, locale: locale as string },\n )\n }\n }\n\n return params\n }\n\n return {\n generateStaticParams,\n getCachedPayload,\n getConfiguredLocales,\n getEntries,\n getSettings,\n resolveRoute,\n robots,\n sitemap,\n }\n}\n"],"names":["getPayload","cache","getArchiveEntries","resolveRoute","resolveRouteRaw","buildRobots","buildSitemapEntries","createContentHelpers","baseUrl","config","content","i18n","pagesSlug","settingsSlug","origin","process","env","NEXT_PUBLIC_SERVER_URL","getCachedPayload","getConfiguredLocales","c","localization","locales","map","l","code","getSettings","locale","payload","findGlobal","slug","depth","segments","page","getEntries","collection","perPage","sitemap","Error","error","console","warn","robots","generateStaticParams","undefined","singleLocale","length","settings","catch","homeId","homepage","id","EXCLUDED","Set","params","result","find","limit","raw","docs","_status","empty","some","p","JSON","stringify","push","rawSlug","Object","values","has","String","split","filter","Boolean"],"mappings":"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"} \ No newline at end of file +{"version":3,"sources":["../../../src/modules/frontend/createContentHelpers.ts"],"sourcesContent":["import type { BasePayload, SanitizedConfig } from 'payload'\n\nimport { getPayload } from 'payload'\nimport { cache } from 'react'\n\nimport type { ArchiveEntries, ContentOption, ResolvedRoute } from '../content/index.js'\nimport type { I18nConfig } from '../i18n/index.js'\nimport type { RobotsRules, SitemapEntry } from '../seo/index.js'\n\nimport { getArchiveEntries, resolveRoute as resolveRouteRaw } from '../content/index.js'\nimport { buildRobots, buildSitemapEntries } from '../seo/index.js'\n\ntype CreateContentHelpersArgs = {\n /**\n * Absolute site origin for sitemap/robots URLs. Falls back to\n * NEXT_PUBLIC_SERVER_URL, then to a relative origin (which most crawlers\n * reject, so set one in production).\n */\n baseUrl?: string\n /**\n * The client's payload config promise (the default export of payload.config).\n * Passed in because the plugin never imports the client's config directly.\n */\n config: Promise | SanitizedConfig\n /** Archive-backed collections, same value as the plugin option. */\n content?: ContentOption\n /**\n * i18n config. Required only if you want the ready-made `sitemap` / `robots`\n * handlers — they need the locale list to emit hreflang.\n */\n i18n?: I18nConfig\n /** Pages collection slug. Defaults to 'pages'. */\n pagesSlug?: string\n /** SiteSettings global slug. Defaults to 'site-settings'. */\n settingsSlug?: string\n}\n\n/**\n * Bundles the per-request data helpers a frontend needs — the same cached\n * wrappers every project was writing by hand (getPayload, settings, locale\n * list, route resolution, archive entries).\n *\n * Everything is wrapped in React `cache()`, so within one request a value is\n * fetched once no matter how many times it's asked for — which matters because\n * Next runs generateMetadata and the page component separately, and both hit\n * these. Crucially the Payload instance is cached *here*, once, so every helper\n * shares it; that's why this is a factory and not loose functions importing a\n * shared module.\n *\n * ```ts\n * // src/lib/content.ts\n * import { createContentHelpers } from 'ipal-kit'\n * import config from '@/payload.config'\n * import { contentConfig } from '@/content.config'\n *\n * export const { getCachedPayload, getSettings, getConfiguredLocales, resolveRoute, getEntries } =\n * createContentHelpers({ config, content: contentConfig })\n * ```\n *\n * `resolveRoute` and `getEntries` are separate on purpose: routing decides what\n * a URL is, fetching gets the listing. Metadata generation needs the first and\n * not the second, and a page component composes them in two obvious lines.\n */\nexport function createContentHelpers({\n baseUrl,\n config,\n content,\n i18n,\n pagesSlug = 'pages',\n settingsSlug = 'site-settings',\n}: CreateContentHelpersArgs) {\n const origin = baseUrl ?? process.env.NEXT_PUBLIC_SERVER_URL ?? ''\n const getCachedPayload = cache(async (): Promise =>\n getPayload({ config: await config }),\n )\n\n const getConfiguredLocales = cache(async (): Promise => {\n const c = await config\n return c.localization ? c.localization.locales.map((l) => l.code) : []\n })\n\n const getSettings = cache(async (locale: string) => {\n const payload = await getCachedPayload()\n return payload.findGlobal({ slug: settingsSlug as never, depth: 2, locale: locale as never })\n })\n\n /** What does this URL point at? Routing only — no listing data. */\n const resolveRoute = cache(\n async (\n locale: string,\n segments: string[] | undefined,\n page: number,\n ): Promise => {\n const payload = await getCachedPayload()\n return resolveRouteRaw({ content, locale, page, pagesSlug, payload, segments, settingsSlug })\n },\n )\n\n /** One page of a collection's entries, for an archive listing. */\n const getEntries = cache(\n async (\n collection: string,\n locale: string,\n page: number,\n perPage: number,\n ): Promise => {\n const payload = await getCachedPayload()\n return getArchiveEntries({ collection, locale, page, payload, perPage })\n },\n )\n\n /**\n * Ready-made handler for Next's `app/sitemap.ts` — every page and entry with\n * per-URL hreflang and lastmod. Re-export it directly:\n *\n * ```ts\n * // app/sitemap.ts\n * export { sitemap as default } from '@/lib/content'\n * export const dynamic = 'force-dynamic' // generate at runtime, not build\n * ```\n *\n * IMPORTANT — container deploys (Coolify/Docker/Railway/CI): Next treats\n * sitemap.ts as STATIC by default and prerenders it during `next build`, which\n * calls into Payload → the database. The build container usually has no access\n * to the internal Docker network, so the DB connection fails (ENOTFOUND) and\n * the build dies. Two defenses:\n * 1. `export const dynamic = 'force-dynamic'` in app/sitemap.ts — skips build\n * prerender, generates at runtime when the DB is reachable (recommended).\n * 2. This handler also catches DB errors and returns [] so that even without\n * (1) the build won't crash — it just ships an empty sitemap until the\n * next runtime regeneration. (1) is still preferred; (2) is a safety net.\n */\n const sitemap = cache(async (): Promise => {\n if (!i18n) {\n throw new Error('[ipal] createContentHelpers: pass `i18n` to use the sitemap handler.')\n }\n try {\n return await buildSitemapEntries({\n baseUrl: origin,\n config: i18n,\n content,\n pagesSlug,\n payload: await getCachedPayload(),\n settingsSlug,\n })\n } catch (error) {\n // DB unreachable (typically a container build with no DB network) — return\n // an empty sitemap instead of failing the build. Runtime regeneration will\n // produce the real one once the DB is reachable. See dynamic='force-dynamic'.\n console.warn(\n '[ipal] sitemap: could not reach the database, returning empty entries ' +\n \"(add `export const dynamic = 'force-dynamic'` to app/sitemap.ts to \" +\n 'generate at runtime and avoid build-time DB access):',\n error,\n )\n return []\n }\n })\n\n /**\n * Ready-made handler for Next's `app/robots.ts`. Re-export directly:\n *\n * ```ts\n * // app/robots.ts\n * export { robots as default } from '@/lib/content'\n * ```\n */\n const robots = (): RobotsRules => buildRobots({ baseUrl: origin })\n\n /**\n * Next.js generateStaticParams for the [[...slug]] route (or\n * [locale]/[[...slug]]). Returns every routable page as a params object, so\n * Next PRE-RENDERS them as static (SSG) instead of dynamic.\n *\n * Why this matters beyond convenience: an optional catch-all with no\n * generateStaticParams is treated as a DYNAMIC route (ƒ), which streams\n * metadata into (crawlers miss it). Providing generateStaticParams\n * compiles routes as SSG (●) — the is synchronous and complete. This is\n * the strongest fix for the metadata-in-head problem (stronger than ISR alone).\n *\n * Handles automatically:\n * - pages collection + content collections (with their archive prefix)\n * - excludes the homepage (maps to { slug: [] } — the root)\n * - excludes drafts and 404/500/system slugs\n * - KEEPS noindex pages (they must still render — noindex controls indexing,\n * not existence; skipping them would force dynamic rendering)\n * - localized slugs (string or per-locale map) both handled\n * - single-locale → { slug }[]; multi-locale → { locale, slug }[]\n *\n * Wire it in the project:\n * // app/(frontend)/[[...slug]]/page.tsx (or [locale]/[[...slug]])\n * export { generateStaticParams } from '@/lib/content'\n */\n const generateStaticParams = async (): Promise<\n Array<{ locale: string; slug: string[] } | { slug: string[] }>\n > => {\n try {\n const payload = await getCachedPayload()\n const locales = i18n ? i18n.locales.map((l) => l.code) : [undefined]\n const singleLocale = !i18n || i18n.locales.length === 1\n\n // Home slug per locale, to exclude the homepage (it's the root, slug []).\n const settings = (await payload\n .findGlobal({ slug: settingsSlug as never, depth: 1, locale: 'all' as never })\n .catch(() => null)) as { homepage?: { id?: number | string; slug?: unknown } } | null\n const homeId = settings?.homepage?.id\n\n const EXCLUDED = new Set(['404', '500', 'error', 'not-found'])\n const params: Array<{ locale: string; slug: string[] } | { slug: string[] }> = []\n\n for (const locale of locales) {\n // NO where:{_status} filter — collections without drafts enabled don't\n // register the _status field, and querying it throws\n // \"path cannot be queried: _status\". We filter drafts in memory below,\n // which is safe for every collection (with or without drafts).\n const result = await payload.find({\n collection: pagesSlug as never,\n depth: 0,\n limit: 1000,\n locale: (locale ?? 'all') as never,\n })\n\n for (const raw of result.docs as Array<{\n _status?: string\n id: number | string\n meta?: { noindex?: boolean } | null\n slug?: unknown\n }>) {\n // Draft filter in memory (safe whether or not the collection has drafts).\n if (raw._status && raw._status !== 'published') {continue}\n\n // NOTE: unlike the sitemap, we do NOT skip meta.noindex here. A noindex\n // page (privacy, cookies, terms) still needs to render — users reach it\n // from the footer and crawlers read its . Pre-render\n // it as SSG so it's fast and its is complete; noindex controls\n // INDEXING, not whether the page exists. Skipping it would force dynamic\n // rendering (the very streaming problem we're avoiding).\n\n if (homeId && raw.id === homeId) {\n // Homepage → root. Emit an empty-slug param so '/' (or '/pl') builds.\n const empty = singleLocale ? { slug: [] } : { slug: [], locale: locale as string }\n if (!params.some((p) => JSON.stringify(p) === JSON.stringify(empty))) {params.push(empty)}\n continue\n }\n\n // Slug may be a plain string OR a localized map ({ pl: 'kontakt' }) when\n // read with locale:'all' or left unflattened. Handle both, or localized\n // pages get silently dropped.\n const rawSlug = raw.slug\n const slug =\n typeof rawSlug === 'string'\n ? rawSlug\n : rawSlug && typeof rawSlug === 'object'\n ? (((rawSlug as Record)[locale ?? ''] as string | undefined) ??\n (Object.values(rawSlug as Record)[0] as string | undefined))\n : undefined\n\n if (!slug || EXCLUDED.has(slug)) {continue}\n\n // Multi-level slugs ('atrakcje/telefon') → array segments.\n const segments = String(slug).split('/').filter(Boolean)\n params.push(\n singleLocale ? { slug: segments } : { slug: segments, locale: locale as string },\n )\n }\n }\n\n return params\n } catch (err) {\n // DB unreachable — typically a container build (Docker/Coolify/CI) with no\n // database network. Return [] so the build doesn't crash: Next falls back\n // to on-demand rendering for the routes, which fill in once the DB is\n // reachable at runtime. Without this every project would need its own\n // try/catch here. (Same graceful-degradation as the sitemap handler.)\n console.warn(\n '[ipal] generateStaticParams: database not reachable during build ' +\n '(Docker/CI) — returning empty params; routes render on-demand at runtime:',\n err,\n )\n return []\n }\n }\n\n return {\n generateStaticParams,\n getCachedPayload,\n getConfiguredLocales,\n getEntries,\n getSettings,\n resolveRoute,\n robots,\n sitemap,\n }\n}\n"],"names":["getPayload","cache","getArchiveEntries","resolveRoute","resolveRouteRaw","buildRobots","buildSitemapEntries","createContentHelpers","baseUrl","config","content","i18n","pagesSlug","settingsSlug","origin","process","env","NEXT_PUBLIC_SERVER_URL","getCachedPayload","getConfiguredLocales","c","localization","locales","map","l","code","getSettings","locale","payload","findGlobal","slug","depth","segments","page","getEntries","collection","perPage","sitemap","Error","error","console","warn","robots","generateStaticParams","undefined","singleLocale","length","settings","catch","homeId","homepage","id","EXCLUDED","Set","params","result","find","limit","raw","docs","_status","empty","some","p","JSON","stringify","push","rawSlug","Object","values","has","String","split","filter","Boolean","err"],"mappings":"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"} \ No newline at end of file diff --git a/docs/deployment.md b/docs/deployment.md index 2240d1d..f9f7c77 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -145,6 +145,29 @@ sitemap/robots → force-dynamic (bo generują przy żądaniu). Nie mieszaj na j trasie. Treść z panelu: ISR = redaktor czeka do rewalidacji; rozważ on-demand revalidation (hook afterChange → revalidatePath). Patrz seo.md, HOOKS.md. +## 3a3. SSG a dostęp do bazy przy buildzie (WAŻNE dla SEO) + +`generateStaticParams` (z lib/content) prerenderuje strony jako SSG — head +synchroniczny, SEO 100/100. ALE żeby prerenderować, **build musi mieć dostęp do +bazy** (generateStaticParams czyta strony z bazy w czasie buildu). + +- **Build MA dostęp do bazy** (baza w tej samej sieci Docker, dostępna w build + stage) → strony prerenderowane jako SSG (`●`), head synchroniczny → SEO OK ✓ +- **Build NIE MA dostępu** (izolowany build stage) → generateStaticParams zwraca + `[]` (plugin łapie błąd, build nie pada), ale strony renderują się on-demand + (dynamicznie) → head może streamować do body → problem SEO wraca ✗ + +Plugin zabezpiecza build przed CRASHEM (try/catch → `[]`), ale to NIE zastępuje +dostępu do bazy. **Dla pełnego SSG/SEO zapewnij, że build kontenerowy widzi bazę.** + +W Coolify/Docker: baza (Mongo/Postgres) powinna być dostępna podczas `pnpm build`, +nie tylko w runtime. Jeśli build jest w izolowanej sieci — rozważ: +- uruchom bazę w tej samej sieci Docker co build stage, albo +- build z DATABASE_URI wskazującym na dostępną bazę (nie wewnętrzny host niedostępny w buildzie). + +Weryfikacja: po buildzie `pnpm build` pokazuje trasy jako `●` (SSG), nie `ƒ` +(Dynamic). Jeśli `ƒ` mimo generateStaticParams → build nie miał dostępu do bazy. + ## 3b. Pułapka: prerender tras zależnych od bazy (KONIECZNE) Next domyślnie **prerenderuje** trasy typu `sitemap.ts` w czasie `next build` — diff --git a/src/modules/frontend/createContentHelpers.ts b/src/modules/frontend/createContentHelpers.ts index f28aa53..4a9779a 100644 --- a/src/modules/frontend/createContentHelpers.ts +++ b/src/modules/frontend/createContentHelpers.ts @@ -194,77 +194,91 @@ export function createContentHelpers({ 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 + 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 + // 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[] }> = [] + 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 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} + 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). + // 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 + 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 }, + ) } - - // 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 + 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 {