1.2.0: local SEO structured data (LocalBusiness, Service, FAQPage), noindex per page
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
type FaqItem = {
|
||||
answer: string
|
||||
question: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds FAQPage JSON-LD (schema.org) from Q&A pairs. Google can show these as
|
||||
* expandable FAQ rich results under the page, taking more SERP space and helping
|
||||
* with voice/AI answers. Strong for service landing pages.
|
||||
*
|
||||
* Feed it the SAME questions/answers rendered on the page (from an FAQ block in
|
||||
* the panel) — the structured data must match visible content, or Google may
|
||||
* flag it. Never invent Q&A that isn't on the page.
|
||||
*
|
||||
* import { buildFaqJsonLd } from '@intecion/ipal-kit'
|
||||
* const jsonLd = buildFaqJsonLd(
|
||||
* faqBlock.items.map(i => ({ question: i.question, answer: i.answer }))
|
||||
* )
|
||||
*
|
||||
* Returns null for empty list.
|
||||
*/
|
||||
export function buildFaqJsonLd(items: FaqItem[]) {
|
||||
if (!items || items.length === 0) {return null}
|
||||
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: items.map((item) => ({
|
||||
name: item.question,
|
||||
'@type': 'Question',
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: item.answer,
|
||||
},
|
||||
})),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
type MediaLike = { url?: null | string } | null | undefined
|
||||
|
||||
type Address = {
|
||||
city?: string
|
||||
country?: string // ISO code, e.g. 'PL'
|
||||
postalCode?: string
|
||||
region?: string
|
||||
street?: string
|
||||
}
|
||||
|
||||
type LocalBusinessJsonLdArgs = {
|
||||
address?: Address
|
||||
/** Geo coordinates for maps/local search. */
|
||||
geo?: { latitude: number; longitude: number }
|
||||
image?: MediaLike
|
||||
logo?: MediaLike
|
||||
name: string
|
||||
/** Opening hours, e.g. ['Mo-Fr 08:00-18:00', 'Sa 09:00-14:00']. */
|
||||
openingHours?: string[]
|
||||
priceRange?: string // e.g. '$$'
|
||||
sameAs?: string[]
|
||||
/** Business phone, e.g. '+48 123 456 789'. */
|
||||
telephone?: string
|
||||
url: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds LocalBusiness JSON-LD (schema.org) — the key structured data for LOCAL
|
||||
* SEO. Helps Google show the business in local results / map pack with address,
|
||||
* hours, phone. Strong signal for "usługa + miasto" queries.
|
||||
*
|
||||
* All data from the panel (company global) — nothing hardcoded. Emit once in the
|
||||
* root layout (business is site-wide):
|
||||
*
|
||||
* import { buildLocalBusinessJsonLd } from '@intecion/ipal-kit'
|
||||
* const jsonLd = buildLocalBusinessJsonLd({
|
||||
* name: company.name, url: baseUrl, telephone: company.phone,
|
||||
* address: company.address, openingHours: company.hours,
|
||||
* })
|
||||
*
|
||||
* For a more specific type (e.g. 'Dentist', 'Plumber'), override @type in the
|
||||
* returned object — schema.org has many LocalBusiness subtypes.
|
||||
*/
|
||||
export function buildLocalBusinessJsonLd({
|
||||
name,
|
||||
address,
|
||||
geo,
|
||||
image,
|
||||
logo,
|
||||
openingHours,
|
||||
priceRange,
|
||||
sameAs,
|
||||
telephone,
|
||||
url,
|
||||
}: LocalBusinessJsonLdArgs) {
|
||||
const logoUrl = logo?.url
|
||||
const imageUrl = image?.url ?? logoUrl
|
||||
|
||||
return {
|
||||
name,
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'LocalBusiness',
|
||||
url,
|
||||
...(telephone ? { telephone } : {}),
|
||||
...(imageUrl ? { image: imageUrl.startsWith('http') ? imageUrl : `${url}${imageUrl}` } : {}),
|
||||
...(logoUrl ? { logo: logoUrl.startsWith('http') ? logoUrl : `${url}${logoUrl}` } : {}),
|
||||
...(address
|
||||
? {
|
||||
address: {
|
||||
'@type': 'PostalAddress',
|
||||
...(address.street ? { streetAddress: address.street } : {}),
|
||||
...(address.city ? { addressLocality: address.city } : {}),
|
||||
...(address.postalCode ? { postalCode: address.postalCode } : {}),
|
||||
...(address.region ? { addressRegion: address.region } : {}),
|
||||
...(address.country ? { addressCountry: address.country } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(geo
|
||||
? { geo: { '@type': 'GeoCoordinates', latitude: geo.latitude, longitude: geo.longitude } }
|
||||
: {}),
|
||||
...(openingHours && openingHours.length > 0 ? { openingHours } : {}),
|
||||
...(priceRange ? { priceRange } : {}),
|
||||
...(sameAs && sameAs.length > 0 ? { sameAs } : {}),
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,10 @@ export type PageMetadata = {
|
||||
locale?: string
|
||||
title: string
|
||||
}
|
||||
robots?: {
|
||||
follow: boolean
|
||||
index: boolean
|
||||
}
|
||||
title: string
|
||||
}
|
||||
|
||||
@@ -112,6 +116,7 @@ export function buildMetadata({
|
||||
...(canonical && { canonical }),
|
||||
...(Object.keys(languages).length > 0 && { languages }),
|
||||
},
|
||||
...(meta?.noindex ? { robots: { follow: true, index: false } } : {}),
|
||||
openGraph: {
|
||||
title,
|
||||
...(description && { description }),
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
type ServiceJsonLdArgs = {
|
||||
/** Area served, e.g. 'Wrocław' or ['Wrocław', 'Oława']. */
|
||||
areaServed?: string | string[]
|
||||
description?: string
|
||||
/** Service name, e.g. 'Sprzątanie biur'. */
|
||||
name: string
|
||||
/** Provider (business) name. */
|
||||
providerName: string
|
||||
/** Service type / category. */
|
||||
serviceType?: string
|
||||
url: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds Service JSON-LD (schema.org) for a service offering. Helps Google
|
||||
* understand "what this page sells" — useful for service landing pages
|
||||
* ("usługa + miasto"). Pairs well with LocalBusiness (the provider).
|
||||
*
|
||||
* Per-page (each service page emits its own), data from the panel:
|
||||
*
|
||||
* import { buildServiceJsonLd } from '@intecion/ipal-kit'
|
||||
* const jsonLd = buildServiceJsonLd({
|
||||
* name: page.serviceName, providerName: company.name,
|
||||
* url: pageUrl, areaServed: 'Wrocław',
|
||||
* })
|
||||
*/
|
||||
export function buildServiceJsonLd({
|
||||
name,
|
||||
areaServed,
|
||||
description,
|
||||
providerName,
|
||||
serviceType,
|
||||
url,
|
||||
}: ServiceJsonLdArgs) {
|
||||
return {
|
||||
name,
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Service',
|
||||
provider: {
|
||||
name: providerName,
|
||||
'@type': 'LocalBusiness',
|
||||
url,
|
||||
},
|
||||
...(description ? { description } : {}),
|
||||
...(areaServed ? { areaServed } : {}),
|
||||
...(serviceType ? { serviceType } : {}),
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
export { buildAutoFillMetaHook } from './autoFillMeta.js'
|
||||
export type { AutoFillMapping } from './autoFillMeta.js'
|
||||
export { buildBreadcrumbJsonLd } from './buildBreadcrumbJsonLd.js'
|
||||
export { buildFaqJsonLd } from './buildFaqJsonLd.js'
|
||||
export { buildIconsMetadata } from './buildIconsMetadata.js'
|
||||
export { buildLocalBusinessJsonLd } from './buildLocalBusinessJsonLd.js'
|
||||
export { buildMetadata } from './buildMetadata.js'
|
||||
export type { PageMetadata } from './buildMetadata.js'
|
||||
export { buildOrganizationJsonLd } from './buildOrganizationJsonLd.js'
|
||||
export { buildRobots } from './buildRobots.js'
|
||||
export type { RobotsRules } from './buildRobots.js'
|
||||
export { buildServiceJsonLd } from './buildServiceJsonLd.js'
|
||||
export { buildSitemapEntries } from './buildSitemapEntries.js'
|
||||
export type { SitemapEntry } from './buildSitemapEntries.js'
|
||||
export { buildSiteNavigationJsonLd } from './buildSiteNavigationJsonLd.js'
|
||||
|
||||
@@ -33,6 +33,7 @@ export type SeoOption = {
|
||||
export type SeoMeta = {
|
||||
description?: null | string
|
||||
image?: unknown
|
||||
noindex?: boolean | null
|
||||
title?: null | string
|
||||
/** When set, used as the whole title — no site name, no separator. */
|
||||
titleOverride?: null | string
|
||||
|
||||
Reference in New Issue
Block a user