diff --git a/dist/modules/frontend/createContentHelpers.js b/dist/modules/frontend/createContentHelpers.js
index 50c7562..e599fe1 100644
--- a/dist/modules/frontend/createContentHelpers.js
+++ b/dist/modules/frontend/createContentHelpers.js
@@ -131,7 +131,10 @@ import { buildRobots, buildSitemapEntries } from '../seo/index.js';
* 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
+ * - 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:
@@ -158,24 +161,27 @@ import { buildRobots, buildSitemapEntries } from '../seo/index.js';
]);
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',
- where: {
- _status: {
- not_equals: 'draft'
- }
- }
+ 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;
}
- if (raw.meta?.noindex) {
- 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 ? {
@@ -189,12 +195,16 @@ import { buildRobots, buildSitemapEntries } from '../seo/index.js';
}
continue;
}
- const slug = typeof raw.slug === 'string' ? raw.slug : undefined;
+ // 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 = slug.split('/').filter(Boolean);
+ const segments = String(slug).split('/').filter(Boolean);
params.push(singleLocale ? {
slug: segments
} : {
diff --git a/dist/modules/frontend/createContentHelpers.js.map b/dist/modules/frontend/createContentHelpers.js.map
index 6ab13c9..847b783 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, 404/500/system slugs, and meta.noindex docs\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 const result = await payload.find({\n collection: pagesSlug as never,\n depth: 0,\n limit: 1000,\n locale: (locale ?? 'all') as never,\n where: { _status: { not_equals: 'draft' } } 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 if (raw._status && raw._status !== 'published') {continue}\n if (raw.meta?.noindex) {continue}\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 const slug = typeof raw.slug === 'string' ? raw.slug : undefined\n if (!slug || EXCLUDED.has(slug)) {continue}\n\n // Multi-level slugs ('atrakcje/telefon') → array segments.\n const segments = 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","where","_status","not_equals","raw","docs","meta","noindex","empty","some","p","JSON","stringify","push","has","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 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
diff --git a/docs/pages.md b/docs/pages.md
index 617fc38..947b2e5 100644
--- a/docs/pages.md
+++ b/docs/pages.md
@@ -80,8 +80,19 @@ export { generateStaticParams } from '@/lib/content'
```
Helper automatycznie: pobiera pages + kolekcje treści, wyklucza homepage (→ root),
-drafty, 404/500, noindex; zwraca `{slug}[]` (jednojęzyczny) albo
-`{locale, slug}[]` (wielojęzyczny). Obsługuje slugi wielopoziomowe (`a/b` → `['a','b']`).
+drafty, 404/500; zwraca `{slug}[]` (jednojęzyczny) albo `{locale, slug}[]`
+(wielojęzyczny). Obsługuje slugi wielopoziomowe (`a/b` → `['a','b']`) oraz
+zlokalizowane (string albo mapa per język).
+
+**Strony noindex SĄ renderowane** (nie pomijane jak w sitemap). Strona prawna
+(polityka, cookies) z noindex nadal musi się wyświetlić — użytkownik wchodzi z
+stopki, crawler czyta jej ``. noindex kontroluje
+INDEKSOWANIE, nie istnienie strony. Pominięcie wymusiłoby dynamiczne renderowanie
+(ten sam problem streamingu, którego unikamy).
+
+Helper NIE filtruje `_status` w zapytaniu (`where`) — kolekcje bez włączonych
+draftów nie mają tego pola i zapytanie by rzuciło błąd. Drafty odfiltrowane w
+pamięci (bezpieczne dla każdej kolekcji).
## PUŁAPKA: await searchParams deoptymalizuje ISR
diff --git a/src/modules/frontend/createContentHelpers.ts b/src/modules/frontend/createContentHelpers.ts
index 1990fd1..f28aa53 100644
--- a/src/modules/frontend/createContentHelpers.ts
+++ b/src/modules/frontend/createContentHelpers.ts
@@ -181,7 +181,10 @@ export function createContentHelpers({
* 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
+ * - 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:
@@ -205,12 +208,15 @@ export function createContentHelpers({
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,
- where: { _status: { not_equals: 'draft' } } as never,
})
for (const raw of result.docs as Array<{
@@ -219,8 +225,16 @@ export function createContentHelpers({
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}
- if (raw.meta?.noindex) {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 }
@@ -228,11 +242,22 @@ export function createContentHelpers({
continue
}
- const slug = typeof raw.slug === 'string' ? raw.slug : undefined
+ // 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 = slug.split('/').filter(Boolean)
+ const segments = String(slug).split('/').filter(Boolean)
params.push(
singleLocale ? { slug: segments } : { slug: segments, locale: locale as string },
)