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 15:31:08 +02:00
parent 87d575f335
commit 33ae035adc
25 changed files with 398 additions and 61 deletions
+12 -5
View File
@@ -4,7 +4,7 @@ import { validateFaviconField } from '../../../modules/seo/index.js'
/**
* General site identity fields.
* Consumed by SEO/OG (siteName, defaultShareImage) and frontend (logo, favicon).
* Consumed by SEO/OG (siteName, defaultShareImage) and frontend (logo).
*/
export const generalFields: Field[] = [
{
@@ -16,6 +16,15 @@ export const generalFields: Field[] = [
localized: true,
required: true,
},
{
name: 'siteDescription',
type: 'textarea',
admin: {
description:
'Fallback meta description when a page has none. Also used for llms.txt and Open Graph.',
},
localized: true,
},
{
name: 'titleOrder',
type: 'select',
@@ -47,8 +56,7 @@ export const generalFields: Field[] = [
name: 'logo',
type: 'upload',
admin: {
description:
'Primary site logo. Also used for Organization structured data (buildOrganizationJsonLd).',
description: 'Primary site logo.',
},
relationTo: 'media',
},
@@ -68,8 +76,7 @@ export const generalFields: Field[] = [
'Square icon, PNG or SVG, min. 48×48px (Google requires this to show it in search). Rendered via buildIconsMetadata.',
},
hooks: {
// Warns the editor at save time if the favicon is too small (<48×48) or
// not square — Google won't display such favicons in search results.
// Warn the editor if the favicon is too small / not square for Google.
beforeValidate: [validateFaviconField],
},
relationTo: 'media',
+1
View File
@@ -103,6 +103,7 @@ export {
buildArticleJsonLd,
buildFaqJsonLd,
buildIconsMetadata,
buildLlmsTxt,
buildLocalBusinessJsonLd,
buildOrganizationJsonLd,
buildServiceJsonLd,
+116
View File
@@ -0,0 +1,116 @@
import type { BasePayload } from 'payload'
import type { I18nConfig } from '../i18n/index.js'
import { buildLocalizedPath } from '../i18n/index.js'
type BuildLlmsTxtArgs = {
/** Absolute site URL (https://…). */
baseUrl: string
config: I18nConfig
/** Locale for names/descriptions. Defaults to config.defaultLocale. */
locale?: string
/** Pages collection slug. Defaults to 'pages'. */
pagesSlug?: string
payload: BasePayload
/** Site settings global slug. Defaults to 'site-settings'. */
settingsSlug?: string
}
type SettingsShape = {
siteDescription?: null | string
siteName?: null | string
}
type PageRow = {
_status?: string
id: number | string
meta?: { description?: unknown; noindex?: boolean } | null
slug?: unknown
title?: unknown
}
/**
* Builds the body of /llms.txt — a Markdown file describing the site for AI
* agents / LLM crawlers, per the llmstxt.org convention. Mirrors buildRobots /
* buildSitemapEntries: the plugin already knows the site's name, description,
* and pages, so it can generate this automatically.
*
* Structure (llmstxt.org): H1 site name, a blockquote/summary, then a list of
* key pages as Markdown links with short descriptions. Agents read this to
* understand the site quickly without crawling everything.
*
* Wire it as a route that returns text/plain:
*
* // app/llms.txt/route.ts
* import { buildLlmsTxt } from '@intecion/ipal-kit'
* import { getCachedPayload } from '@/lib/content'
* import { i18nConfig } from '@/i18n.config'
* export const dynamic = 'force-dynamic'
* export async function GET() {
* const body = await buildLlmsTxt({
* payload: await getCachedPayload(),
* config: i18nConfig,
* baseUrl: process.env.NEXT_PUBLIC_SERVER_URL!,
* })
* return new Response(body, { headers: { 'Content-Type': 'text/plain; charset=utf-8' } })
* }
*
* Data comes from the panel (siteName, siteDescription, pages) — nothing
* hardcoded. Skips drafts and noindex pages (same as the sitemap).
*/
export async function buildLlmsTxt({
baseUrl,
config,
locale,
pagesSlug = 'pages',
payload,
settingsSlug = 'site-settings',
}: BuildLlmsTxtArgs): Promise<string> {
const loc = locale ?? config.defaultLocale
const origin = baseUrl.replace(/\/$/, '')
const settings = (await payload.findGlobal({
slug: settingsSlug as never,
depth: 0,
locale: loc as never,
})) as SettingsShape
const name = settings.siteName?.trim() || 'Website'
const description = settings.siteDescription?.trim()
const result = await payload.find({
collection: pagesSlug as never,
depth: 0,
limit: 1000,
locale: loc as never,
where: { _status: { not_equals: 'draft' } } as never,
})
const lines: string[] = [`# ${name}`, '']
if (description) {
lines.push(`> ${description}`, '')
}
const pageLinks: string[] = []
for (const raw of result.docs as PageRow[]) {
if (raw._status && raw._status !== 'published') {continue}
if (raw.meta?.noindex) {continue}
const title = typeof raw.title === 'string' ? raw.title : undefined
const slug = typeof raw.slug === 'string' ? raw.slug : undefined
if (!title || !slug) {continue}
const path = buildLocalizedPath({ config, locale: loc, slugs: { [loc]: slug } })
if (!path) {continue}
const pageDesc = typeof raw.meta?.description === 'string' ? ` — ${raw.meta.description}` : ''
pageLinks.push(`- [${title}](${origin}${path})${pageDesc}`)
}
if (pageLinks.length > 0) {
lines.push('## Strony', '', ...pageLinks, '')
}
return lines.join('\n')
}
+9 -2
View File
@@ -35,7 +35,7 @@ type BuildMetadataArgs = {
baseUrl?: string
config: I18nConfig
/** Home slug that collapses to the locale root. Defaults to 'home'. */
homeSlug?: string | Record<string, string>
homeSlug?: Record<string, string> | string
/** Resolved OG image URL (page image or site defaultShareImage). */
imageUrl?: null | string
/** Current locale being rendered. */
@@ -67,6 +67,12 @@ type BuildMetadataArgs = {
query?: string
/** Separator between page title and site name. Defaults to ' | '. */
separator?: string
/**
* Global fallback description (from SiteSettings.siteDescription). Used when a
* page has no meta.description — better than emitting no description at all
* (Google would then scrape random on-page text for the snippet).
*/
siteDescription?: null | string
/** Site name for title composition and OG. */
siteName?: null | string
/** slug per locale for this document — drives canonical + hreflang. */
@@ -94,6 +100,7 @@ export function buildMetadata({
prefix,
query,
separator,
siteDescription,
siteName,
slugs,
}: BuildMetadataArgs): PageMetadata {
@@ -105,7 +112,7 @@ export function buildMetadata({
const resolvedPageTitle = meta?.title?.trim() || pageTitle?.trim() || undefined
const title =
override || composeTitle({ order, pageTitle: resolvedPageTitle, separator, siteName })
const description = meta?.description?.trim() || undefined
const description = meta?.description?.trim() || siteDescription?.trim() || undefined
const origin = baseUrl?.replace(/\/$/, '') ?? ''
const suffix = query ?? ''
+1
View File
@@ -4,6 +4,7 @@ export { buildArticleJsonLd } from './buildArticleJsonLd.js'
export { buildBreadcrumbJsonLd } from './buildBreadcrumbJsonLd.js'
export { buildFaqJsonLd } from './buildFaqJsonLd.js'
export { buildIconsMetadata } from './buildIconsMetadata.js'
export { buildLlmsTxt } from './buildLlmsTxt.js'
export { buildLocalBusinessJsonLd } from './buildLocalBusinessJsonLd.js'
export { buildMetadata } from './buildMetadata.js'
export type { PageMetadata } from './buildMetadata.js'
+25 -21
View File
@@ -1,18 +1,15 @@
import type { BasePayload } from 'payload'
import type { TitleOrder } from './composeTitle.js'
type SettingsShape = {
homepage?: { id: string | number; slug?: unknown } | string | number | null
titleSeparator?: string | null
titleOrder?: TitleOrder | null
[key: string]: unknown
homepage?: { id: number | string; slug?: unknown } | null | number | string
titleOrder?: null | TitleOrder
titleSeparator?: null | string
}
export type SiteMetaConfig = {
siteName: string | null
/** Padded separator, e.g. ' | ' — the panel stores the bare character. */
separator?: string
order?: TitleOrder
/**
* Home slug per locale, e.g. { pl: 'strona-glowna', de: 'startseite' }. A map
* (not a single string) so multilingual homepages collapse to root in EVERY
@@ -20,6 +17,12 @@ export type SiteMetaConfig = {
* instead of '/de', breaking return tags (a real GSC error).
*/
homeSlug?: Record<string, string>
order?: TitleOrder
/** Padded separator, e.g. ' | ' — the panel stores the bare character. */
separator?: string
/** Global fallback meta description. */
siteDescription?: null | string
siteName: null | string
}
/**
@@ -31,13 +34,13 @@ export type SiteMetaConfig = {
* relationship — it should receive a resolved config.
*/
export async function readSiteMetaConfig({
payload,
locale,
settingsSlug = 'site-settings',
siteNameField = 'siteName',
}: {
payload: BasePayload
locale,
payload,
settingsSlug = 'site-settings',
siteNameField = 'siteName',
}: {
locale: string
payload: BasePayload
settingsSlug?: string
siteNameField?: string
}): Promise<SiteMetaConfig> {
@@ -45,8 +48,8 @@ export async function readSiteMetaConfig({
// without a second query.
const settings = (await payload.findGlobal({
slug: settingsSlug as never,
locale: locale as never,
depth: 1,
locale: locale as never,
})) as SettingsShape
const homepage =
@@ -60,18 +63,18 @@ export async function readSiteMetaConfig({
if (homepage?.id) {
try {
const homeDoc = (await payload.findByID({
collection: 'pages' as never,
id: homepage.id,
locale: 'all' as never,
collection: 'pages' as never,
depth: 0,
locale: 'all' as never,
})) as { slug?: unknown }
const slugField = homeDoc.slug
if (slugField && typeof slugField === 'object') {
const map: Record<string, string> = {}
for (const [loc, value] of Object.entries(slugField as Record<string, unknown>)) {
if (typeof value === 'string') map[loc] = value
if (typeof value === 'string') {map[loc] = value}
}
if (Object.keys(map).length > 0) homeSlug = map
if (Object.keys(map).length > 0) {homeSlug = map}
} else if (typeof slugField === 'string') {
// Non-localized slug (same in every language) — key it by current locale;
// buildLocalizedPath tolerates a partial map. Non-localized homepage
@@ -90,10 +93,11 @@ export async function readSiteMetaConfig({
}
return {
siteDescription: (settings.siteDescription as string | undefined) ?? null,
siteName: (settings[siteNameField] as string | undefined) ?? null,
// The panel stores '|'; titles need it padded.
separator: settings.titleSeparator ? ` ${settings.titleSeparator} ` : undefined,
order: settings.titleOrder ?? undefined,
homeSlug,
order: settings.titleOrder ?? undefined,
separator: settings.titleSeparator ? ` ${settings.titleSeparator} ` : undefined,
}
}
}