SEO: multilingual homepage (homeSlug per locale in path/sitemap), Article + llms.txt generators, siteDescription fallback, composeTitle dedup

This commit is contained in:
2026-09-15 21:16:53 +02:00
parent 55a1e5bf8f
commit 0f03bb7446
5 changed files with 283 additions and 61 deletions
+105 -16
View File
@@ -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 <body> (crawlers miss it). Providing generateStaticParams
* compiles routes as SSG (●) — the <head> is synchronous and complete. This is
* the strongest fix for the metadata-in-head problem (stronger than ISR alone).
*
* Handles automatically:
* - pages collection + content collections (with their archive prefix)
* - excludes the homepage (maps to { slug: [] } — the root)
* - excludes drafts, 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
};
}