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 }, )