1.2.0: local SEO structured data (LocalBusiness, Service, FAQPage), noindex per page

This commit is contained in:
2026-09-08 12:49:49 +02:00
parent e39e2a361a
commit 068415849f
34 changed files with 1030 additions and 104 deletions
+58 -35
View File
@@ -1,22 +1,14 @@
import type { BasePayload, SanitizedConfig } from 'payload'
import { getPayload } from 'payload'
import { cache } from 'react'
import type { BasePayload, SanitizedConfig } from 'payload'
import { getPayload } from 'payload'
import type { ArchiveEntries, ContentOption, ResolvedRoute } from '../content/index.js'
import type { ContentOption, ResolvedRoute, ArchiveEntries } from '../content/index.js'
import { resolveRoute as resolveRouteRaw, getArchiveEntries } from '../content/index.js'
import type { I18nConfig } from '../i18n/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'
import type { SitemapEntry, RobotsRules } from '../seo/index.js'
import { buildSitemapEntries, buildRobots } 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.
@@ -24,15 +16,21 @@ type CreateContentHelpersArgs = {
config: Promise<SanitizedConfig> | 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
/** Pages collection slug. Defaults to 'pages'. */
pagesSlug?: string
/** SiteSettings global slug. Defaults to 'site-settings'. */
settingsSlug?: string
/**
* 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
}
/**
@@ -62,12 +60,12 @@ type CreateContentHelpersArgs = {
* not the second, and a page component composes them in two obvious lines.
*/
export function createContentHelpers({
baseUrl,
config,
content,
i18n,
pagesSlug = 'pages',
settingsSlug = 'site-settings',
pagesSlug = 'pages',
i18n,
baseUrl,
}: CreateContentHelpersArgs) {
const origin = baseUrl ?? process.env.NEXT_PUBLIC_SERVER_URL ?? ''
const getCachedPayload = cache(async (): Promise<BasePayload> =>
@@ -81,7 +79,7 @@ export function createContentHelpers({
const getSettings = cache(async (locale: string) => {
const payload = await getCachedPayload()
return payload.findGlobal({ slug: settingsSlug as never, depth: 2, locale: locale as never })
return payload.findGlobal({ slug: settingsSlug as never, locale: locale as never, depth: 2 })
})
/** What does this URL point at? Routing only — no listing data. */
@@ -90,9 +88,9 @@ export function createContentHelpers({
locale: string,
segments: string[] | undefined,
page: number,
): Promise<null | ResolvedRoute> => {
): Promise<ResolvedRoute | null> => {
const payload = await getCachedPayload()
return resolveRouteRaw({ content, locale, page, pagesSlug, payload, segments, settingsSlug })
return resolveRouteRaw({ payload, locale, segments, page, content, pagesSlug, settingsSlug })
},
)
@@ -105,7 +103,7 @@ export function createContentHelpers({
perPage: number,
): Promise<ArchiveEntries> => {
const payload = await getCachedPayload()
return getArchiveEntries({ collection, locale, page, payload, perPage })
return getArchiveEntries({ payload, collection, locale, page, perPage })
},
)
@@ -116,20 +114,45 @@ export function createContentHelpers({
* ```ts
* // app/sitemap.ts
* export { sitemap as default } from '@/lib/content'
* export const dynamic = 'force-dynamic' // generate at runtime, not build
* ```
*
* IMPORTANT — container deploys (Coolify/Docker/Railway/CI): Next treats
* sitemap.ts as STATIC by default and prerenders it during `next build`, which
* calls into Payload → the database. The build container usually has no access
* to the internal Docker network, so the DB connection fails (ENOTFOUND) and
* the build dies. Two defenses:
* 1. `export const dynamic = 'force-dynamic'` in app/sitemap.ts — skips build
* prerender, generates at runtime when the DB is reachable (recommended).
* 2. This handler also catches DB errors and returns [] so that even without
* (1) the build won't crash — it just ships an empty sitemap until the
* next runtime regeneration. (1) is still preferred; (2) is a safety net.
*/
const sitemap = cache(async (): Promise<SitemapEntry[]> => {
if (!i18n) {
throw new Error('[ipal] createContentHelpers: pass `i18n` to use the sitemap handler.')
}
return buildSitemapEntries({
baseUrl: origin,
config: i18n,
content,
pagesSlug,
payload: await getCachedPayload(),
settingsSlug,
})
try {
return await buildSitemapEntries({
payload: await getCachedPayload(),
config: i18n,
baseUrl: origin,
content,
pagesSlug,
settingsSlug,
})
} catch (error) {
// DB unreachable (typically a container build with no DB network) — return
// an empty sitemap instead of failing the build. Runtime regeneration will
// produce the real one once the DB is reachable. See dynamic='force-dynamic'.
console.warn(
'[ipal] sitemap: could not reach the database, returning empty entries ' +
"(add `export const dynamic = 'force-dynamic'` to app/sitemap.ts to " +
'generate at runtime and avoid build-time DB access):',
error,
)
return []
}
})
/**
@@ -145,10 +168,10 @@ export function createContentHelpers({
return {
getCachedPayload,
getConfiguredLocales,
getEntries,
getSettings,
resolveRoute,
robots,
getEntries,
sitemap,
robots,
}
}