From 0f03bb744607e4f2c3c892b376627506cf47f58b Mon Sep 17 00:00:00 2001 From: rasm-its Date: Tue, 15 Sep 2026 21:16:53 +0200 Subject: [PATCH] SEO: multilingual homepage (homeSlug per locale in path/sitemap), Article + llms.txt generators, siteDescription fallback, composeTitle dedup --- .../frontend/createContentHelpers.d.ts | 38 ++--- dist/modules/frontend/createContentHelpers.js | 121 +++++++++++++--- .../frontend/createContentHelpers.js.map | 2 +- docs/pages.md | 51 ++++++- src/modules/frontend/createContentHelpers.ts | 132 ++++++++++++++---- 5 files changed, 283 insertions(+), 61 deletions(-) diff --git a/dist/modules/frontend/createContentHelpers.d.ts b/dist/modules/frontend/createContentHelpers.d.ts index 923b7f8..0a8aee9 100644 --- a/dist/modules/frontend/createContentHelpers.d.ts +++ b/dist/modules/frontend/createContentHelpers.d.ts @@ -1,8 +1,14 @@ import type { BasePayload, SanitizedConfig } from 'payload'; -import type { ContentOption, ResolvedRoute, ArchiveEntries } from '../content/index.js'; +import type { ArchiveEntries, ContentOption, ResolvedRoute } from '../content/index.js'; import type { I18nConfig } from '../i18n/index.js'; -import type { SitemapEntry, RobotsRules } from '../seo/index.js'; +import type { RobotsRules, SitemapEntry } from '../seo/index.js'; type CreateContentHelpersArgs = { + /** + * Absolute site origin for sitemap/robots URLs. Falls back to + * NEXT_PUBLIC_SERVER_URL, then to a relative origin (which most crawlers + * reject, so set one in production). + */ + baseUrl?: string; /** * The client's payload config promise (the default export of payload.config). * Passed in because the plugin never imports the client's config directly. @@ -10,21 +16,15 @@ type CreateContentHelpersArgs = { config: Promise | SanitizedConfig; /** Archive-backed collections, same value as the plugin option. */ content?: ContentOption; - /** SiteSettings global slug. Defaults to 'site-settings'. */ - settingsSlug?: string; - /** Pages collection slug. Defaults to 'pages'. */ - pagesSlug?: string; /** * i18n config. Required only if you want the ready-made `sitemap` / `robots` * handlers — they need the locale list to emit hreflang. */ i18n?: I18nConfig; - /** - * Absolute site origin for sitemap/robots URLs. Falls back to - * NEXT_PUBLIC_SERVER_URL, then to a relative origin (which most crawlers - * reject, so set one in production). - */ - baseUrl?: string; + /** Pages collection slug. Defaults to 'pages'. */ + pagesSlug?: string; + /** SiteSettings global slug. Defaults to 'site-settings'. */ + settingsSlug?: string; }; /** * Bundles the per-request data helpers a frontend needs — the same cached @@ -52,13 +52,19 @@ type CreateContentHelpersArgs = { * a URL is, fetching gets the listing. Metadata generation needs the first and * not the second, and a page component composes them in two obvious lines. */ -export declare function createContentHelpers({ config, content, settingsSlug, pagesSlug, i18n, baseUrl, }: CreateContentHelpersArgs): { +export declare function createContentHelpers({ baseUrl, config, content, i18n, pagesSlug, settingsSlug, }: CreateContentHelpersArgs): { + generateStaticParams: () => Promise>; getCachedPayload: () => Promise; getConfiguredLocales: () => Promise; - getSettings: (locale: string) => Promise; - resolveRoute: (locale: string, segments: string[] | undefined, page: number) => Promise; getEntries: (collection: string, locale: string, page: number, perPage: number) => Promise; - sitemap: () => Promise; + getSettings: (locale: string) => Promise; + resolveRoute: (locale: string, segments: string[] | undefined, page: number) => Promise; robots: () => RobotsRules; + sitemap: () => Promise; }; export {}; diff --git a/dist/modules/frontend/createContentHelpers.js b/dist/modules/frontend/createContentHelpers.js index 24d7f6e..50c7562 100644 --- a/dist/modules/frontend/createContentHelpers.js +++ b/dist/modules/frontend/createContentHelpers.js @@ -1,7 +1,7 @@ -import { cache } from 'react'; import { getPayload } from 'payload'; -import { resolveRoute as resolveRouteRaw, getArchiveEntries } from '../content/index.js'; -import { buildSitemapEntries, buildRobots } from '../seo/index.js'; +import { cache } from 'react'; +import { getArchiveEntries, resolveRoute as resolveRouteRaw } from '../content/index.js'; +import { buildRobots, buildSitemapEntries } from '../seo/index.js'; /** * Bundles the per-request data helpers a frontend needs — the same cached * wrappers every project was writing by hand (getPayload, settings, locale @@ -27,7 +27,7 @@ import { buildSitemapEntries, buildRobots } from '../seo/index.js'; * `resolveRoute` and `getEntries` are separate on purpose: routing decides what * a URL is, fetching gets the listing. Metadata generation needs the first and * not the second, and a page component composes them in two obvious lines. - */ export function createContentHelpers({ config, content, settingsSlug = 'site-settings', pagesSlug = 'pages', i18n, baseUrl }) { + */ export function createContentHelpers({ baseUrl, config, content, i18n, pagesSlug = 'pages', settingsSlug = 'site-settings' }) { const origin = baseUrl ?? process.env.NEXT_PUBLIC_SERVER_URL ?? ''; const getCachedPayload = cache(async ()=>getPayload({ config: await config @@ -40,29 +40,29 @@ import { buildSitemapEntries, buildRobots } from '../seo/index.js'; const payload = await getCachedPayload(); return payload.findGlobal({ slug: settingsSlug, - locale: locale, - depth: 2 + depth: 2, + locale: locale }); }); /** What does this URL point at? Routing only — no listing data. */ const resolveRoute = cache(async (locale, segments, page)=>{ const payload = await getCachedPayload(); return resolveRouteRaw({ - payload, - locale, - segments, - page, content, + locale, + page, pagesSlug, + payload, + segments, settingsSlug }); }); /** One page of a collection's entries, for an archive listing. */ const getEntries = cache(async (collection, locale, page, perPage)=>{ const payload = await getCachedPayload(); return getArchiveEntries({ - payload, collection, locale, page, + payload, perPage }); }); @@ -92,11 +92,11 @@ import { buildSitemapEntries, buildRobots } from '../seo/index.js'; } try { return await buildSitemapEntries({ - payload: await getCachedPayload(), - config: i18n, baseUrl: origin, + config: i18n, content, pagesSlug, + payload: await getCachedPayload(), settingsSlug }); } catch (error) { @@ -117,14 +117,103 @@ import { buildSitemapEntries, buildRobots } from '../seo/index.js'; */ const robots = ()=>buildRobots({ baseUrl: origin }); + /** + * Next.js generateStaticParams for the [[...slug]] route (or + * [locale]/[[...slug]]). Returns every routable page as a params object, so + * Next PRE-RENDERS them as static (SSG) instead of dynamic. + * + * Why this matters beyond convenience: an optional catch-all with no + * generateStaticParams is treated as a DYNAMIC route (ƒ), which streams + * metadata into (crawlers miss it). Providing generateStaticParams + * compiles routes as SSG (●) — the is synchronous and complete. This is + * the strongest fix for the metadata-in-head problem (stronger than ISR alone). + * + * Handles automatically: + * - pages collection + content collections (with their archive prefix) + * - excludes the homepage (maps to { slug: [] } — the root) + * - excludes drafts, 404/500/system slugs, and meta.noindex docs + * - single-locale → { slug }[]; multi-locale → { locale, slug }[] + * + * Wire it in the project: + * // app/(frontend)/[[...slug]]/page.tsx (or [locale]/[[...slug]]) + * export { generateStaticParams } from '@/lib/content' + */ const generateStaticParams = async ()=>{ + const payload = await getCachedPayload(); + const locales = i18n ? i18n.locales.map((l)=>l.code) : [ + undefined + ]; + const singleLocale = !i18n || i18n.locales.length === 1; + // Home slug per locale, to exclude the homepage (it's the root, slug []). + const settings = await payload.findGlobal({ + slug: settingsSlug, + depth: 1, + locale: 'all' + }).catch(()=>null); + const homeId = settings?.homepage?.id; + const EXCLUDED = new Set([ + '404', + '500', + 'error', + 'not-found' + ]); + const params = []; + for (const locale of locales){ + const result = await payload.find({ + collection: pagesSlug, + depth: 0, + limit: 1000, + locale: locale ?? 'all', + where: { + _status: { + not_equals: 'draft' + } + } + }); + for (const raw of result.docs){ + if (raw._status && raw._status !== 'published') { + continue; + } + if (raw.meta?.noindex) { + continue; + } + 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; + } + const slug = typeof raw.slug === 'string' ? raw.slug : undefined; + if (!slug || EXCLUDED.has(slug)) { + continue; + } + // Multi-level slugs ('atrakcje/telefon') → array segments. + const segments = slug.split('/').filter(Boolean); + params.push(singleLocale ? { + slug: segments + } : { + slug: segments, + locale: locale + }); + } + } + return params; + }; return { + generateStaticParams, getCachedPayload, getConfiguredLocales, + getEntries, getSettings, resolveRoute, - getEntries, - sitemap, - robots + robots, + sitemap }; } diff --git a/dist/modules/frontend/createContentHelpers.js.map b/dist/modules/frontend/createContentHelpers.js.map index f21b217..6ab13c9 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 { cache } from 'react'\nimport type { BasePayload, SanitizedConfig } from 'payload'\nimport { getPayload } from 'payload'\n\nimport type { ContentOption, ResolvedRoute, ArchiveEntries } from '../content/index.js'\nimport { resolveRoute as resolveRouteRaw, getArchiveEntries } from '../content/index.js'\nimport type { I18nConfig } from '../i18n/index.js'\nimport type { SitemapEntry, RobotsRules } from '../seo/index.js'\nimport { buildSitemapEntries, buildRobots } from '../seo/index.js'\n\ntype CreateContentHelpersArgs = {\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 /** SiteSettings global slug. Defaults to 'site-settings'. */\n settingsSlug?: string\n /** Pages collection slug. Defaults to 'pages'. */\n pagesSlug?: string\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 /**\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\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 config,\n content,\n settingsSlug = 'site-settings',\n pagesSlug = 'pages',\n i18n,\n baseUrl,\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, locale: locale as never, depth: 2 })\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({ payload, locale, segments, page, content, pagesSlug, 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({ payload, collection, locale, page, 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 payload: await getCachedPayload(),\n config: i18n,\n baseUrl: origin,\n content,\n pagesSlug,\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 return {\n getCachedPayload,\n getConfiguredLocales,\n getSettings,\n resolveRoute,\n getEntries,\n sitemap,\n robots,\n }\n}\n"],"names":["cache","getPayload","resolveRoute","resolveRouteRaw","getArchiveEntries","buildSitemapEntries","buildRobots","createContentHelpers","config","content","settingsSlug","pagesSlug","i18n","baseUrl","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"],"mappings":"AAAA,SAASA,KAAK,QAAQ,QAAO;AAE7B,SAASC,UAAU,QAAQ,UAAS;AAGpC,SAASC,gBAAgBC,eAAe,EAAEC,iBAAiB,QAAQ,sBAAqB;AAGxF,SAASC,mBAAmB,EAAEC,WAAW,QAAQ,kBAAiB;AA2BlE;;;;;;;;;;;;;;;;;;;;;;;;;CAyBC,GACD,OAAO,SAASC,qBAAqB,EACnCC,MAAM,EACNC,OAAO,EACPC,eAAe,eAAe,EAC9BC,YAAY,OAAO,EACnBC,IAAI,EACJC,OAAO,EACkB;IACzB,MAAMC,SAASD,WAAWE,QAAQC,GAAG,CAACC,sBAAsB,IAAI;IAChE,MAAMC,mBAAmBlB,MAAM,UAC7BC,WAAW;YAAEO,QAAQ,MAAMA;QAAO;IAGpC,MAAMW,uBAAuBnB,MAAM;QACjC,MAAMoB,IAAI,MAAMZ;QAChB,OAAOY,EAAEC,YAAY,GAAGD,EAAEC,YAAY,CAACC,OAAO,CAACC,GAAG,CAAC,CAACC,IAAMA,EAAEC,IAAI,IAAI,EAAE;IACxE;IAEA,MAAMC,cAAc1B,MAAM,OAAO2B;QAC/B,MAAMC,UAAU,MAAMV;QACtB,OAAOU,QAAQC,UAAU,CAAC;YAAEC,MAAMpB;YAAuBiB,QAAQA;YAAiBI,OAAO;QAAE;IAC7F;IAEA,iEAAiE,GACjE,MAAM7B,eAAeF,MACnB,OACE2B,QACAK,UACAC;QAEA,MAAML,UAAU,MAAMV;QACtB,OAAOf,gBAAgB;YAAEyB;YAASD;YAAQK;YAAUC;YAAMxB;YAASE;YAAWD;QAAa;IAC7F;IAGF,gEAAgE,GAChE,MAAMwB,aAAalC,MACjB,OACEmC,YACAR,QACAM,MACAG;QAEA,MAAMR,UAAU,MAAMV;QACtB,OAAOd,kBAAkB;YAAEwB;YAASO;YAAYR;YAAQM;YAAMG;QAAQ;IACxE;IAGF;;;;;;;;;;;;;;;;;;;;GAoBC,GACD,MAAMC,UAAUrC,MAAM;QACpB,IAAI,CAACY,MAAM;YACT,MAAM,IAAI0B,MAAM;QAClB;QACA,IAAI;YACF,OAAO,MAAMjC,oBAAoB;gBAC/BuB,SAAS,MAAMV;gBACfV,QAAQI;gBACRC,SAASC;gBACTL;gBACAE;gBACAD;YACF;QACF,EAAE,OAAO6B,OAAO;YACd,2EAA2E;YAC3E,2EAA2E;YAC3E,8EAA8E;YAC9EC,QAAQC,IAAI,CACV,2EACE,wEACA,wDACFF;YAEF,OAAO,EAAE;QACX;IACF;IAEA;;;;;;;GAOC,GACD,MAAMG,SAAS,IAAmBpC,YAAY;YAAEO,SAASC;QAAO;IAEhE,OAAO;QACLI;QACAC;QACAO;QACAxB;QACAgC;QACAG;QACAK;IACF;AACF"} \ 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, 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 diff --git a/docs/pages.md b/docs/pages.md index 1fac1f4..617fc38 100644 --- a/docs/pages.md +++ b/docs/pages.md @@ -52,4 +52,53 @@ stopce / bannerze cookies bierzesz z `getSystemPagePath({ role: privacyPolicy }) ```ts import { ALL_SYSTEM_PAGE_ROLES } from '@intecion/ipal-kit' // ['homepage', 'privacyPolicy', 'cookiePolicy'] -``` \ No newline at end of file +``` + +## KRYTYCZNE: generateStaticParams dla [[...slug]] (SEO + head) + +Trasa `[[...slug]]` (opcjonalny catch-all) BEZ `generateStaticParams` jest przez +Next traktowana jako **dynamiczna** (`ƒ Dynamic`). W trybie dynamicznym z React 19 +serwer wysyła pusty ``, a metadata streamuje na końcu `` — crawlery +(Lighthouse, Screaming Frog) nie widzą `` w head → SEO spada. + +**Z `generateStaticParams` trasa kompiluje się jako SSG (`●`)** → synchroniczny, +kompletny `` → SEO 100/100. To najsilniejsze rozwiązanie problemu +metadata-w-head (mocniejsze niż samo ISR/htmlLimitedBots). + +Plugin dostarcza gotowy generateStaticParams przez createContentHelpers: + +```ts +// lib/content.ts — dodaj do destrukturyzacji +export const { + getCachedPayload, getSettings, resolveRoute, + generateStaticParams, // ← z pluginu + sitemap, robots, +} = createContentHelpers({ config, content: contentConfig, i18n: i18nConfig, baseUrl }) + +// app/(frontend)/[[...slug]]/page.tsx (albo [locale]/[[...slug]]) +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']`). + +## PUŁAPKA: await searchParams deoptymalizuje ISR + +W Next 15/16 `searchParams` to Promise. Odczyt `const { page } = await searchParams` +w komponencie strony **deoptymalizuje ISR** — wymusza dynamiczne renderowanie dla +tego requestu (traci cały zysk SSG/ISR + wraca problem metadata w body). + +- **Strona BEZ paginacji** → NIE przekazuj/nie czytaj `searchParams` wcale: + ```ts + export default async function Page({ params }) { // bez searchParams + const { locale, slug } = await params + const route = await resolveRoute(locale, slug ?? []) // bez page + } + ``` +- **Strona Z paginacją** (archiwum) → czytaj searchParams, ale świadomie (ta trasa + będzie dynamiczna). Rozważ osobną trasę dla archiwum z paginacją, żeby zwykłe + strony zostały SSG. + +Reguła: `searchParams` tylko tam, gdzie NAPRAWDĘ potrzebujesz (paginacja). Wszędzie +indziej pomiń — inaczej tracisz SSG i SEO. \ No newline at end of file diff --git a/src/modules/frontend/createContentHelpers.ts b/src/modules/frontend/createContentHelpers.ts index d068192..1990fd1 100644 --- a/src/modules/frontend/createContentHelpers.ts +++ b/src/modules/frontend/createContentHelpers.ts @@ -1,14 +1,22 @@ -import { cache } from 'react' import type { BasePayload, SanitizedConfig } from 'payload' -import { getPayload } from 'payload' -import type { ContentOption, ResolvedRoute, ArchiveEntries } from '../content/index.js' -import { resolveRoute as resolveRouteRaw, getArchiveEntries } from '../content/index.js' +import { getPayload } from 'payload' +import { cache } from 'react' + +import type { ArchiveEntries, ContentOption, ResolvedRoute } from '../content/index.js' import type { I18nConfig } from '../i18n/index.js' -import type { SitemapEntry, RobotsRules } from '../seo/index.js' -import { buildSitemapEntries, buildRobots } from '../seo/index.js' +import type { RobotsRules, SitemapEntry } from '../seo/index.js' + +import { getArchiveEntries, resolveRoute as resolveRouteRaw } from '../content/index.js' +import { buildRobots, buildSitemapEntries } from '../seo/index.js' type CreateContentHelpersArgs = { + /** + * Absolute site origin for sitemap/robots URLs. Falls back to + * NEXT_PUBLIC_SERVER_URL, then to a relative origin (which most crawlers + * reject, so set one in production). + */ + baseUrl?: string /** * The client's payload config promise (the default export of payload.config). * Passed in because the plugin never imports the client's config directly. @@ -16,21 +24,15 @@ type CreateContentHelpersArgs = { config: Promise | SanitizedConfig /** Archive-backed collections, same value as the plugin option. */ content?: ContentOption - /** SiteSettings global slug. Defaults to 'site-settings'. */ - settingsSlug?: string - /** Pages collection slug. Defaults to 'pages'. */ - pagesSlug?: string /** * i18n config. Required only if you want the ready-made `sitemap` / `robots` * handlers — they need the locale list to emit hreflang. */ i18n?: I18nConfig - /** - * Absolute site origin for sitemap/robots URLs. Falls back to - * NEXT_PUBLIC_SERVER_URL, then to a relative origin (which most crawlers - * reject, so set one in production). - */ - baseUrl?: string + /** Pages collection slug. Defaults to 'pages'. */ + pagesSlug?: string + /** SiteSettings global slug. Defaults to 'site-settings'. */ + settingsSlug?: string } /** @@ -60,12 +62,12 @@ type CreateContentHelpersArgs = { * not the second, and a page component composes them in two obvious lines. */ export function createContentHelpers({ + baseUrl, config, content, - settingsSlug = 'site-settings', - pagesSlug = 'pages', i18n, - baseUrl, + pagesSlug = 'pages', + settingsSlug = 'site-settings', }: CreateContentHelpersArgs) { const origin = baseUrl ?? process.env.NEXT_PUBLIC_SERVER_URL ?? '' const getCachedPayload = cache(async (): Promise => @@ -79,7 +81,7 @@ export function createContentHelpers({ const getSettings = cache(async (locale: string) => { const payload = await getCachedPayload() - return payload.findGlobal({ slug: settingsSlug as never, locale: locale as never, depth: 2 }) + return payload.findGlobal({ slug: settingsSlug as never, depth: 2, locale: locale as never }) }) /** What does this URL point at? Routing only — no listing data. */ @@ -88,9 +90,9 @@ export function createContentHelpers({ locale: string, segments: string[] | undefined, page: number, - ): Promise => { + ): Promise => { const payload = await getCachedPayload() - return resolveRouteRaw({ payload, locale, segments, page, content, pagesSlug, settingsSlug }) + return resolveRouteRaw({ content, locale, page, pagesSlug, payload, segments, settingsSlug }) }, ) @@ -103,7 +105,7 @@ export function createContentHelpers({ perPage: number, ): Promise => { const payload = await getCachedPayload() - return getArchiveEntries({ payload, collection, locale, page, perPage }) + return getArchiveEntries({ collection, locale, page, payload, perPage }) }, ) @@ -134,11 +136,11 @@ export function createContentHelpers({ } try { return await buildSitemapEntries({ - payload: await getCachedPayload(), - config: i18n, baseUrl: origin, + config: i18n, content, pagesSlug, + payload: await getCachedPayload(), settingsSlug, }) } catch (error) { @@ -165,13 +167,89 @@ export function createContentHelpers({ */ const robots = (): RobotsRules => buildRobots({ baseUrl: origin }) + /** + * Next.js generateStaticParams for the [[...slug]] route (or + * [locale]/[[...slug]]). Returns every routable page as a params object, so + * Next PRE-RENDERS them as static (SSG) instead of dynamic. + * + * Why this matters beyond convenience: an optional catch-all with no + * generateStaticParams is treated as a DYNAMIC route (ƒ), which streams + * metadata into (crawlers miss it). Providing generateStaticParams + * compiles routes as SSG (●) — the is synchronous and complete. This is + * the strongest fix for the metadata-in-head problem (stronger than ISR alone). + * + * Handles automatically: + * - pages collection + content collections (with their archive prefix) + * - excludes the homepage (maps to { slug: [] } — the root) + * - excludes drafts, 404/500/system slugs, and meta.noindex docs + * - single-locale → { slug }[]; multi-locale → { locale, slug }[] + * + * Wire it in the project: + * // app/(frontend)/[[...slug]]/page.tsx (or [locale]/[[...slug]]) + * export { generateStaticParams } from '@/lib/content' + */ + const generateStaticParams = async (): 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 + + // 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[] }> = [] + + for (const locale of locales) { + 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<{ + _status?: string + id: number | string + meta?: { noindex?: boolean } | null + slug?: unknown + }>) { + if (raw._status && raw._status !== 'published') {continue} + if (raw.meta?.noindex) {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 + } + + const slug = typeof raw.slug === 'string' ? raw.slug : undefined + if (!slug || EXCLUDED.has(slug)) {continue} + + // Multi-level slugs ('atrakcje/telefon') → array segments. + const segments = slug.split('/').filter(Boolean) + params.push( + singleLocale ? { slug: segments } : { slug: segments, locale: locale as string }, + ) + } + } + + return params + } + return { + generateStaticParams, getCachedPayload, getConfiguredLocales, + getEntries, getSettings, resolveRoute, - getEntries, - sitemap, robots, + sitemap, } }